Repository: basecamp/fizzy Branch: main Commit: bcf6e9213199 Files: 1525 Total size: 2.1 MB Directory structure: gitextract_3xz67y80/ ├── .claude/ │ └── CLAUDE.md ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── config.yml │ │ └── preapproved.md │ ├── dependabot.yml │ └── workflows/ │ ├── ci-checks.yml │ ├── ci-oss.yml │ ├── ci-saas.yml │ ├── dependabot-sync-saas-lockfile.yml │ ├── publish-image.yml │ └── test.yml ├── .gitignore ├── .gitleaks.toml ├── .gitleaksignore ├── .mise.toml ├── .rubocop.yml ├── .ruby-version ├── AGENTS.md ├── CONTRIBUTING.md ├── Dockerfile ├── Gemfile ├── Gemfile.saas ├── LICENSE.md ├── README.md ├── Rakefile ├── STYLE.md ├── app/ │ ├── assets/ │ │ ├── images/ │ │ │ └── .keep │ │ └── stylesheets/ │ │ ├── _global.css │ │ ├── access-tokens.css │ │ ├── android.css │ │ ├── animation.css │ │ ├── attachments.css │ │ ├── autoresize.css │ │ ├── avatars.css │ │ ├── bar.css │ │ ├── base.css │ │ ├── blank-slates.css │ │ ├── bubble.css │ │ ├── buttons.css │ │ ├── card-columns.css │ │ ├── card-perma.css │ │ ├── cards.css │ │ ├── circled-text.css │ │ ├── color-picker.css │ │ ├── comments.css │ │ ├── credentials.css │ │ ├── dialog.css │ │ ├── dividers.css │ │ ├── drag_and_drop.css │ │ ├── events.css │ │ ├── expandable.css │ │ ├── filters.css │ │ ├── flash.css │ │ ├── font-face.css │ │ ├── golden-effect.css │ │ ├── header.css │ │ ├── icons.css │ │ ├── import.css │ │ ├── inputs.css │ │ ├── ios.css │ │ ├── knobs.css │ │ ├── layout.css │ │ ├── lexxy.css │ │ ├── lightbox.css │ │ ├── markdown.css │ │ ├── native.css │ │ ├── nav.css │ │ ├── notifications.css │ │ ├── pagination.css │ │ ├── panels.css │ │ ├── performance-notice.css │ │ ├── pins.css │ │ ├── popup.css │ │ ├── print.css │ │ ├── pwa.css │ │ ├── qr-codes.css │ │ ├── reactions.css │ │ ├── reset.css │ │ ├── search.css │ │ ├── separators.css │ │ ├── settings.css │ │ ├── spinners.css │ │ ├── steps.css │ │ ├── syntax.css │ │ ├── theme-switcher.css │ │ ├── toggles.css │ │ ├── tooltips.css │ │ ├── trays.css │ │ ├── user.css │ │ ├── utilities.css │ │ └── welcome-letter.css │ ├── channels/ │ │ └── application_cable/ │ │ └── connection.rb │ ├── controllers/ │ │ ├── account/ │ │ │ ├── cancellations_controller.rb │ │ │ ├── entropies_controller.rb │ │ │ ├── exports_controller.rb │ │ │ ├── imports_controller.rb │ │ │ ├── join_codes_controller.rb │ │ │ └── settings_controller.rb │ │ ├── admin_controller.rb │ │ ├── application_controller.rb │ │ ├── boards/ │ │ │ ├── columns/ │ │ │ │ ├── closeds_controller.rb │ │ │ │ ├── not_nows_controller.rb │ │ │ │ └── streams_controller.rb │ │ │ ├── columns_controller.rb │ │ │ ├── entropies_controller.rb │ │ │ ├── involvements_controller.rb │ │ │ └── publications_controller.rb │ │ ├── boards_controller.rb │ │ ├── cards/ │ │ │ ├── assignments_controller.rb │ │ │ ├── boards_controller.rb │ │ │ ├── closures_controller.rb │ │ │ ├── columns_controller.rb │ │ │ ├── comments/ │ │ │ │ └── reactions_controller.rb │ │ │ ├── comments_controller.rb │ │ │ ├── drafts_controller.rb │ │ │ ├── goldnesses_controller.rb │ │ │ ├── images_controller.rb │ │ │ ├── not_nows_controller.rb │ │ │ ├── pins_controller.rb │ │ │ ├── previews_controller.rb │ │ │ ├── publishes_controller.rb │ │ │ ├── reactions_controller.rb │ │ │ ├── readings_controller.rb │ │ │ ├── self_assignments_controller.rb │ │ │ ├── steps_controller.rb │ │ │ ├── taggings_controller.rb │ │ │ ├── triages_controller.rb │ │ │ └── watches_controller.rb │ │ ├── cards_controller.rb │ │ ├── client_configurations_controller.rb │ │ ├── columns/ │ │ │ ├── cards/ │ │ │ │ └── drops/ │ │ │ │ ├── closures_controller.rb │ │ │ │ ├── columns_controller.rb │ │ │ │ ├── not_nows_controller.rb │ │ │ │ └── streams_controller.rb │ │ │ ├── left_positions_controller.rb │ │ │ └── right_positions_controller.rb │ │ ├── concerns/ │ │ │ ├── authentication/ │ │ │ │ └── via_magic_link.rb │ │ │ ├── authentication.rb │ │ │ ├── authorization.rb │ │ │ ├── block_search_engine_indexing.rb │ │ │ ├── board_scoped.rb │ │ │ ├── card_scoped.rb │ │ │ ├── column_scoped.rb │ │ │ ├── current_request.rb │ │ │ ├── current_timezone.rb │ │ │ ├── day_timelines_scoped.rb │ │ │ ├── filter_scoped.rb │ │ │ ├── request_forgery_protection.rb │ │ │ ├── routing_headers.rb │ │ │ ├── set_platform.rb │ │ │ ├── turbo_flash.rb │ │ │ └── view_transitions.rb │ │ ├── events/ │ │ │ ├── day_timeline/ │ │ │ │ └── columns_controller.rb │ │ │ └── days_controller.rb │ │ ├── events_controller.rb │ │ ├── filters/ │ │ │ └── settings_refreshes_controller.rb │ │ ├── filters_controller.rb │ │ ├── join_codes_controller.rb │ │ ├── landings_controller.rb │ │ ├── my/ │ │ │ ├── access_tokens_controller.rb │ │ │ ├── identities_controller.rb │ │ │ ├── menus_controller.rb │ │ │ ├── passkey_challenges_controller.rb │ │ │ ├── passkeys_controller.rb │ │ │ ├── pins_controller.rb │ │ │ └── timezones_controller.rb │ │ ├── notifications/ │ │ │ ├── bulk_readings_controller.rb │ │ │ ├── readings_controller.rb │ │ │ ├── settings_controller.rb │ │ │ ├── trays_controller.rb │ │ │ └── unsubscribes_controller.rb │ │ ├── notifications_controller.rb │ │ ├── prompts/ │ │ │ ├── boards/ │ │ │ │ └── users_controller.rb │ │ │ ├── cards_controller.rb │ │ │ ├── tags_controller.rb │ │ │ └── users_controller.rb │ │ ├── public/ │ │ │ ├── base_controller.rb │ │ │ ├── boards/ │ │ │ │ ├── columns/ │ │ │ │ │ ├── closeds_controller.rb │ │ │ │ │ ├── not_nows_controller.rb │ │ │ │ │ └── streams_controller.rb │ │ │ │ └── columns_controller.rb │ │ │ ├── boards_controller.rb │ │ │ └── cards_controller.rb │ │ ├── pwa_controller.rb │ │ ├── qr_codes_controller.rb │ │ ├── searches/ │ │ │ └── queries_controller.rb │ │ ├── searches_controller.rb │ │ ├── sessions/ │ │ │ ├── magic_links_controller.rb │ │ │ ├── menus_controller.rb │ │ │ ├── passkeys_controller.rb │ │ │ └── transfers_controller.rb │ │ ├── sessions_controller.rb │ │ ├── signups/ │ │ │ └── completions_controller.rb │ │ ├── signups_controller.rb │ │ ├── tags_controller.rb │ │ ├── users/ │ │ │ ├── avatars_controller.rb │ │ │ ├── data_exports_controller.rb │ │ │ ├── email_addresses/ │ │ │ │ └── confirmations_controller.rb │ │ │ ├── email_addresses_controller.rb │ │ │ ├── events_controller.rb │ │ │ ├── joins_controller.rb │ │ │ ├── push_subscriptions_controller.rb │ │ │ ├── roles_controller.rb │ │ │ └── verifications_controller.rb │ │ ├── users_controller.rb │ │ ├── webhooks/ │ │ │ └── activations_controller.rb │ │ └── webhooks_controller.rb │ ├── helpers/ │ │ ├── accesses_helper.rb │ │ ├── application_helper.rb │ │ ├── avatars_helper.rb │ │ ├── boards_helper.rb │ │ ├── bridge_helper.rb │ │ ├── cards_helper.rb │ │ ├── clipboard_helper.rb │ │ ├── columns_helper.rb │ │ ├── comments_helper.rb │ │ ├── emoji_helper.rb │ │ ├── entropy_helper.rb │ │ ├── events_helper.rb │ │ ├── excerpt_helper.rb │ │ ├── filters_helper.rb │ │ ├── forms_helper.rb │ │ ├── hotkeys_helper.rb │ │ ├── html_helper.rb │ │ ├── login_helper.rb │ │ ├── messages_helper.rb │ │ ├── my/ │ │ │ └── menu_helper.rb │ │ ├── notifications_helper.rb │ │ ├── pagination_helper.rb │ │ ├── qr_codes_helper.rb │ │ ├── reactions_helper.rb │ │ ├── rich_text_helper.rb │ │ ├── tenanting_helper.rb │ │ ├── time_helper.rb │ │ ├── users_helper.rb │ │ └── webhooks_helper.rb │ ├── javascript/ │ │ ├── application.js │ │ ├── controllers/ │ │ │ ├── application.js │ │ │ ├── assignment_limit_controller.js │ │ │ ├── auto_click_controller.js │ │ │ ├── auto_save_controller.js │ │ │ ├── auto_submit_controller.js │ │ │ ├── autoresize_controller.js │ │ │ ├── badge_controller.js │ │ │ ├── bar_controller.js │ │ │ ├── beacon_controller.js │ │ │ ├── boards_form_controller.js │ │ │ ├── bridge/ │ │ │ │ ├── buttons_controller.js │ │ │ │ ├── form_controller.js │ │ │ │ ├── insets_controller.js │ │ │ │ ├── overflow_menu_controller.js │ │ │ │ ├── share_controller.js │ │ │ │ ├── stamp_controller.js │ │ │ │ ├── text_size_controller.js │ │ │ │ └── title_controller.js │ │ │ ├── bubble_controller.js │ │ │ ├── card_hotkeys_controller.js │ │ │ ├── clear_offline_cache_controller.js │ │ │ ├── clicker_controller.js │ │ │ ├── collapsible_columns_controller.js │ │ │ ├── combobox_controller.js │ │ │ ├── copy_to_clipboard_controller.js │ │ │ ├── css_variable_counter_controller.js │ │ │ ├── details_controller.js │ │ │ ├── dialog_controller.js │ │ │ ├── dialog_manager_controller.js │ │ │ ├── drag_and_drop_controller.js │ │ │ ├── drag_and_strum_controller.js │ │ │ ├── element_removal_controller.js │ │ │ ├── expandable_on_native_controller.js │ │ │ ├── fetch_on_visible_controller.js │ │ │ ├── filter_controller.js │ │ │ ├── filter_form_controller.js │ │ │ ├── filter_settings_controller.js │ │ │ ├── form_controller.js │ │ │ ├── frame_controller.js │ │ │ ├── frame_reloader_controller.js │ │ │ ├── hotkey_controller.js │ │ │ ├── index.js │ │ │ ├── knob_controller.js │ │ │ ├── lightbox_controller.js │ │ │ ├── local_save_controller.js │ │ │ ├── local_time_controller.js │ │ │ ├── magic_link_controller.js │ │ │ ├── multi_selection_combobox_controller.js │ │ │ ├── nav_section_expander_controller.js │ │ │ ├── navigable_list_controller.js │ │ │ ├── notifications_controller.js │ │ │ ├── outlet_auto_save_controller.js │ │ │ ├── pagination_controller.js │ │ │ ├── reaction_delete_controller.js │ │ │ ├── reaction_emoji_controller.js │ │ │ ├── related_element_controller.js │ │ │ ├── retarget_links_controller.js │ │ │ ├── scroll_to_controller.js │ │ │ ├── search_form_controller.js │ │ │ ├── soft_keyboard_controller.js │ │ │ ├── syntax_highlight_controller.js │ │ │ ├── theme_controller.js │ │ │ ├── timezone_cookie_controller.js │ │ │ ├── toggle_class_controller.js │ │ │ ├── toggle_enable_controller.js │ │ │ ├── tooltip_controller.js │ │ │ ├── touch_placeholder_controller.js │ │ │ ├── turbo_navigation_controller.js │ │ │ └── upload_preview_controller.js │ │ ├── helpers/ │ │ │ ├── bridge/ │ │ │ │ └── viewport_helpers.js │ │ │ ├── date_helpers.js │ │ │ ├── form_helpers.js │ │ │ ├── html_helpers.js │ │ │ ├── orientation_helpers.js │ │ │ ├── platform_helpers.js │ │ │ ├── scroll_helpers.js │ │ │ ├── text_helpers.js │ │ │ └── timing_helpers.js │ │ ├── initializers/ │ │ │ ├── bridge/ │ │ │ │ └── bridge_element.js │ │ │ ├── current.js │ │ │ ├── index.js │ │ │ ├── lexxy_markdown_paste.js │ │ │ └── offline.js │ │ └── lib/ │ │ └── action_pack/ │ │ ├── passkey.js │ │ └── webauthn.js │ ├── jobs/ │ │ ├── account/ │ │ │ ├── data_import_job.rb │ │ │ └── incinerate_due_job.rb │ │ ├── application_job.rb │ │ ├── board/ │ │ │ └── clean_inaccessible_data_job.rb │ │ ├── card/ │ │ │ ├── activity_spike/ │ │ │ │ └── detection_job.rb │ │ │ ├── clean_inaccessible_data_job.rb │ │ │ └── remove_inaccessible_notifications_job.rb │ │ ├── concerns/ │ │ │ └── smtp_delivery_error_handling.rb │ │ ├── data_export_job.rb │ │ ├── delete_unused_tags_job.rb │ │ ├── event/ │ │ │ └── webhook_dispatch_job.rb │ │ ├── mention/ │ │ │ └── create_job.rb │ │ ├── notification/ │ │ │ ├── bundle/ │ │ │ │ ├── deliver_all_job.rb │ │ │ │ └── deliver_job.rb │ │ │ └── push_job.rb │ │ ├── notify_recipients_job.rb │ │ ├── push_notification_job.rb │ │ ├── storage/ │ │ │ ├── materialize_job.rb │ │ │ └── reconcile_job.rb │ │ └── webhook/ │ │ └── delivery_job.rb │ ├── mailers/ │ │ ├── account_mailer.rb │ │ ├── application_mailer.rb │ │ ├── concerns/ │ │ │ └── mailers/ │ │ │ └── unsubscribable.rb │ │ ├── export_mailer.rb │ │ ├── import_mailer.rb │ │ ├── magic_link_mailer.rb │ │ ├── notification/ │ │ │ └── bundle_mailer.rb │ │ └── user_mailer.rb │ ├── models/ │ │ ├── access.rb │ │ ├── account/ │ │ │ ├── cancellable.rb │ │ │ ├── cancellation.rb │ │ │ ├── data_transfer/ │ │ │ │ ├── account_record_set.rb │ │ │ │ ├── action_text/ │ │ │ │ │ └── rich_text_record_set.rb │ │ │ │ ├── active_storage/ │ │ │ │ │ ├── attachment_record_set.rb │ │ │ │ │ ├── blob_record_set.rb │ │ │ │ │ └── file_record_set.rb │ │ │ │ ├── entropy_record_set.rb │ │ │ │ ├── manifest.rb │ │ │ │ ├── record_set.rb │ │ │ │ └── user_record_set.rb │ │ │ ├── entropic.rb │ │ │ ├── export.rb │ │ │ ├── external_id_sequence.rb │ │ │ ├── import.rb │ │ │ ├── incineratable.rb │ │ │ ├── join_code.rb │ │ │ ├── multi_tenantable.rb │ │ │ ├── seedeable.rb │ │ │ ├── seeder.rb │ │ │ └── storage.rb │ │ ├── account.rb │ │ ├── admin.rb │ │ ├── application_platform.rb │ │ ├── application_record.rb │ │ ├── assignment.rb │ │ ├── board/ │ │ │ ├── accessible.rb │ │ │ ├── auto_postponing.rb │ │ │ ├── broadcastable.rb │ │ │ ├── cards.rb │ │ │ ├── entropic.rb │ │ │ ├── publication.rb │ │ │ ├── publishable.rb │ │ │ ├── storage.rb │ │ │ └── triageable.rb │ │ ├── board.rb │ │ ├── card/ │ │ │ ├── accessible.rb │ │ │ ├── activity_spike/ │ │ │ │ └── detector.rb │ │ │ ├── activity_spike.rb │ │ │ ├── assignable.rb │ │ │ ├── broadcastable.rb │ │ │ ├── closeable.rb │ │ │ ├── colored.rb │ │ │ ├── commentable.rb │ │ │ ├── entropic.rb │ │ │ ├── entropy.rb │ │ │ ├── eventable/ │ │ │ │ └── system_commenter.rb │ │ │ ├── eventable.rb │ │ │ ├── exportable.rb │ │ │ ├── golden.rb │ │ │ ├── goldness.rb │ │ │ ├── mentions.rb │ │ │ ├── multistep.rb │ │ │ ├── not_now.rb │ │ │ ├── pinnable.rb │ │ │ ├── postponable.rb │ │ │ ├── promptable.rb │ │ │ ├── readable.rb │ │ │ ├── searchable.rb │ │ │ ├── stallable.rb │ │ │ ├── statuses.rb │ │ │ ├── taggable.rb │ │ │ ├── triageable.rb │ │ │ └── watchable.rb │ │ ├── card.rb │ │ ├── closure.rb │ │ ├── color.rb │ │ ├── column/ │ │ │ ├── colored.rb │ │ │ └── positioned.rb │ │ ├── column.rb │ │ ├── comment/ │ │ │ ├── eventable.rb │ │ │ ├── mentions.rb │ │ │ ├── promptable.rb │ │ │ └── searchable.rb │ │ ├── comment.rb │ │ ├── concerns/ │ │ │ ├── attachments.rb │ │ │ ├── eventable.rb │ │ │ ├── filterable.rb │ │ │ ├── mentions.rb │ │ │ ├── notifiable.rb │ │ │ ├── searchable.rb │ │ │ └── storage/ │ │ │ ├── totaled.rb │ │ │ └── tracked.rb │ │ ├── current.rb │ │ ├── entropy.rb │ │ ├── event/ │ │ │ ├── description.rb │ │ │ ├── particulars.rb │ │ │ └── promptable.rb │ │ ├── event.rb │ │ ├── export.rb │ │ ├── filter/ │ │ │ ├── fields.rb │ │ │ ├── params.rb │ │ │ ├── resources.rb │ │ │ └── summarized.rb │ │ ├── filter.rb │ │ ├── identity/ │ │ │ ├── access_token.rb │ │ │ ├── joinable.rb │ │ │ └── transferable.rb │ │ ├── identity.rb │ │ ├── magic_link/ │ │ │ └── code.rb │ │ ├── magic_link.rb │ │ ├── mention.rb │ │ ├── notification/ │ │ │ ├── bundle.rb │ │ │ ├── default_payload.rb │ │ │ ├── event_payload.rb │ │ │ ├── mention_payload.rb │ │ │ ├── push_target/ │ │ │ │ └── web.rb │ │ │ ├── push_target.rb │ │ │ └── pushable.rb │ │ ├── notification.rb │ │ ├── notifier/ │ │ │ ├── card_event_notifier.rb │ │ │ ├── comment_event_notifier.rb │ │ │ └── mention_notifier.rb │ │ ├── notifier.rb │ │ ├── passkey/ │ │ │ └── authenticator.rb │ │ ├── pin.rb │ │ ├── push/ │ │ │ └── subscription.rb │ │ ├── push.rb │ │ ├── qr_code_link.rb │ │ ├── reaction.rb │ │ ├── search/ │ │ │ ├── highlighter.rb │ │ │ ├── query.rb │ │ │ ├── record/ │ │ │ │ ├── sqlite/ │ │ │ │ │ └── fts.rb │ │ │ │ ├── sqlite.rb │ │ │ │ └── trilogy.rb │ │ │ ├── record.rb │ │ │ ├── result.rb │ │ │ └── stemmer.rb │ │ ├── search.rb │ │ ├── session.rb │ │ ├── signup/ │ │ │ └── account_name_generator.rb │ │ ├── signup.rb │ │ ├── ssrf_protection.rb │ │ ├── step.rb │ │ ├── storage/ │ │ │ ├── attachment_tracking.rb │ │ │ ├── entry.rb │ │ │ └── total.rb │ │ ├── storage.rb │ │ ├── tag/ │ │ │ └── attachable.rb │ │ ├── tag.rb │ │ ├── tagging.rb │ │ ├── time_window_parser.rb │ │ ├── user/ │ │ │ ├── accessor.rb │ │ │ ├── assignee.rb │ │ │ ├── attachable.rb │ │ │ ├── avatar.rb │ │ │ ├── configurable.rb │ │ │ ├── data_export.rb │ │ │ ├── day_timeline/ │ │ │ │ ├── column.rb │ │ │ │ └── serializable.rb │ │ │ ├── day_timeline.rb │ │ │ ├── email_address_changeable.rb │ │ │ ├── filtering.rb │ │ │ ├── mentionable.rb │ │ │ ├── named.rb │ │ │ ├── notifiable.rb │ │ │ ├── role.rb │ │ │ ├── searcher.rb │ │ │ ├── settings.rb │ │ │ ├── timelined.rb │ │ │ ├── transferable.rb │ │ │ └── watcher.rb │ │ ├── user.rb │ │ ├── watch.rb │ │ ├── webhook/ │ │ │ ├── delinquency_tracker.rb │ │ │ ├── delivery.rb │ │ │ └── triggerable.rb │ │ ├── webhook.rb │ │ ├── zip_file/ │ │ │ ├── reader/ │ │ │ │ └── io.rb │ │ │ ├── reader.rb │ │ │ ├── remote_io.rb │ │ │ └── writer.rb │ │ └── zip_file.rb │ └── views/ │ ├── account/ │ │ ├── exports/ │ │ │ ├── show.html.erb │ │ │ └── show.json.jbuilder │ │ ├── imports/ │ │ │ ├── new.html.erb │ │ │ └── show.html.erb │ │ ├── join_codes/ │ │ │ ├── edit.html.erb │ │ │ ├── show.html.erb │ │ │ └── show.json.jbuilder │ │ └── settings/ │ │ ├── _cancellation.html.erb │ │ ├── _entropy.html.erb │ │ ├── _export.html.erb │ │ ├── _name.html.erb │ │ ├── _user.html.erb │ │ ├── _users.html.erb │ │ ├── show.html.erb │ │ └── show.json.jbuilder │ ├── action_text/ │ │ └── attachables/ │ │ ├── _remote_image.html.erb │ │ └── _remote_video.html.erb │ ├── active_storage/ │ │ └── blobs/ │ │ ├── _blob.html.erb │ │ └── web/ │ │ └── _representation.html.erb │ ├── bar/ │ │ └── _bar.html.erb │ ├── boards/ │ │ ├── _access_toggle.html.erb │ │ ├── _board.json.jbuilder │ │ ├── columns/ │ │ │ ├── _empty_placeholder.html.erb │ │ │ ├── closeds/ │ │ │ │ ├── show.html.erb │ │ │ │ └── show.json.jbuilder │ │ │ ├── create.turbo_stream.erb │ │ │ ├── index.json.jbuilder │ │ │ ├── not_nows/ │ │ │ │ ├── show.html.erb │ │ │ │ └── show.json.jbuilder │ │ │ ├── show.html.erb │ │ │ ├── show.json.jbuilder │ │ │ ├── streams/ │ │ │ │ ├── show.html.erb │ │ │ │ └── show.json.jbuilder │ │ │ └── update.turbo_stream.erb │ │ ├── edit/ │ │ │ ├── _auto_close.html.erb │ │ │ ├── _delete.html.erb │ │ │ ├── _name.html.erb │ │ │ ├── _publication.html.erb │ │ │ └── _users.html.erb │ │ ├── edit.html.erb │ │ ├── entropies/ │ │ │ └── update.turbo_stream.erb │ │ ├── index.json.jbuilder │ │ ├── involvements/ │ │ │ └── update.html.erb │ │ ├── new.html.erb │ │ ├── publications/ │ │ │ ├── create.turbo_stream.erb │ │ │ └── destroy.turbo_stream.erb │ │ ├── show/ │ │ │ ├── _closed.html.erb │ │ │ ├── _column.html.erb │ │ │ ├── _columns.html.erb │ │ │ ├── _expander.html.erb │ │ │ ├── _filtered_cards.html.erb │ │ │ ├── _not_now.html.erb │ │ │ ├── _stream.html.erb │ │ │ └── menu/ │ │ │ ├── _column.html.erb │ │ │ ├── _column_form.html.erb │ │ │ ├── _columns.html.erb │ │ │ └── _maximize.html.erb │ │ ├── show.html.erb │ │ └── show.json.jbuilder │ ├── cards/ │ │ ├── _broadcasts.html.erb │ │ ├── _card.json.jbuilder │ │ ├── _container.html.erb │ │ ├── _delete.html.erb │ │ ├── _messages.html.erb │ │ ├── assignments/ │ │ │ ├── _user.html.erb │ │ │ ├── create.turbo_stream.erb │ │ │ └── new.html.erb │ │ ├── boards/ │ │ │ └── edit.html.erb │ │ ├── closures/ │ │ │ ├── create.turbo_stream.erb │ │ │ └── destroy.turbo_stream.erb │ │ ├── columns/ │ │ │ ├── _column.html.erb │ │ │ └── edit.html.erb │ │ ├── comments/ │ │ │ ├── _comment.html.erb │ │ │ ├── _comment.json.jbuilder │ │ │ ├── _new.html.erb │ │ │ ├── _watchers.html.erb │ │ │ ├── create.turbo_stream.erb │ │ │ ├── destroy.turbo_stream.erb │ │ │ ├── edit.html.erb │ │ │ ├── index.json.jbuilder │ │ │ ├── show.html.erb │ │ │ ├── show.json.jbuilder │ │ │ └── update.turbo_stream.erb │ │ ├── container/ │ │ │ ├── _closure.html.erb │ │ │ ├── _closure_buttons.html.erb │ │ │ ├── _content.html.erb │ │ │ ├── _content_display.html.erb │ │ │ ├── _gild.html.erb │ │ │ ├── _image.html.erb │ │ │ ├── _save_button.html.erb │ │ │ └── footer/ │ │ │ ├── _create.html.erb │ │ │ └── _published.html.erb │ │ ├── display/ │ │ │ ├── _preview.html.erb │ │ │ ├── _previews.html.erb │ │ │ ├── _public_preview.html.erb │ │ │ ├── _public_previews.html.erb │ │ │ ├── common/ │ │ │ │ ├── _assignees.html.erb │ │ │ │ ├── _background.html.erb │ │ │ │ ├── _board.html.erb │ │ │ │ ├── _meta.html.erb │ │ │ │ └── _stamp.html.erb │ │ │ ├── mini/ │ │ │ │ ├── _assignees.html.erb │ │ │ │ ├── _meta.html.erb │ │ │ │ └── _tags.html.erb │ │ │ ├── perma/ │ │ │ │ ├── _assignees.html.erb │ │ │ │ ├── _background.html.erb │ │ │ │ ├── _board.html.erb │ │ │ │ ├── _meta.html.erb │ │ │ │ ├── _steps.html.erb │ │ │ │ └── _tags.html.erb │ │ │ ├── preview/ │ │ │ │ ├── _assignees.html.erb │ │ │ │ ├── _board.html.erb │ │ │ │ ├── _boosts.html.erb │ │ │ │ ├── _bubble.html.erb │ │ │ │ ├── _columns.html.erb │ │ │ │ ├── _comments.html.erb │ │ │ │ ├── _meta.html.erb │ │ │ │ ├── _people.html.erb │ │ │ │ ├── _steps.html.erb │ │ │ │ └── _tags.html.erb │ │ │ └── public_preview/ │ │ │ ├── _columns.html.erb │ │ │ └── _meta.html.erb │ │ ├── drafts/ │ │ │ ├── _container.html.erb │ │ │ └── show.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ ├── index.json.jbuilder │ │ ├── not_nows/ │ │ │ └── create.turbo_stream.erb │ │ ├── pins/ │ │ │ ├── _pin_button.html.erb │ │ │ └── show.html.erb │ │ ├── previews/ │ │ │ └── index.turbo_stream.erb │ │ ├── readings/ │ │ │ └── create.turbo_stream.erb │ │ ├── show.html.erb │ │ ├── show.json.jbuilder │ │ ├── steps/ │ │ │ ├── _step.html.erb │ │ │ ├── _step.json.jbuilder │ │ │ ├── create.turbo_stream.erb │ │ │ ├── destroy.turbo_stream.erb │ │ │ ├── edit.html.erb │ │ │ ├── index.json.jbuilder │ │ │ ├── show.html.erb │ │ │ ├── show.json.jbuilder │ │ │ └── update.turbo_stream.erb │ │ ├── taggings/ │ │ │ ├── _tag.html.erb │ │ │ ├── create.turbo_stream.erb │ │ │ └── new.html.erb │ │ ├── triage/ │ │ │ └── _columns.html.erb │ │ ├── update.turbo_stream.erb │ │ └── watches/ │ │ ├── _refresh.turbo_stream.erb │ │ ├── _watch_button.html.erb │ │ ├── create.turbo_stream.erb │ │ ├── destroy.turbo_stream.erb │ │ └── show.html.erb │ ├── client_configurations/ │ │ ├── android_v1.json │ │ └── ios_v1.json │ ├── columns/ │ │ ├── _column.json.jbuilder │ │ ├── _refresh_adjacent_columns.turbo_stream.erb │ │ ├── cards/ │ │ │ └── drops/ │ │ │ ├── closures/ │ │ │ │ └── create.turbo_stream.erb │ │ │ ├── columns/ │ │ │ │ └── create.turbo_stream.erb │ │ │ ├── not_nows/ │ │ │ │ └── create.turbo_stream.erb │ │ │ └── streams/ │ │ │ └── create.turbo_stream.erb │ │ ├── left_positions/ │ │ │ └── create.turbo_stream.erb │ │ ├── right_positions/ │ │ │ └── create.turbo_stream.erb │ │ └── show/ │ │ └── _add_card_button.html.erb │ ├── entropy/ │ │ ├── _auto_close.html.erb │ │ └── _knob.html.erb │ ├── event_summaries/ │ │ └── _event_summary.html.erb │ ├── events/ │ │ ├── _day.html.erb │ │ ├── _empty_days.html.erb │ │ ├── _event.html.erb │ │ ├── day_timeline/ │ │ │ ├── _column.html.erb │ │ │ ├── _columns.html.erb │ │ │ └── columns/ │ │ │ ├── _events.html.erb │ │ │ └── show.html.erb │ │ ├── days/ │ │ │ └── index.html.erb │ │ ├── event/ │ │ │ ├── _attachments.html.erb │ │ │ ├── _layout.html.erb │ │ │ ├── attachments/ │ │ │ │ ├── _attachment.html.erb │ │ │ │ ├── _remote_image.html.erb │ │ │ │ └── _remote_video.html.erb │ │ │ └── eventable/ │ │ │ ├── _card.html.erb │ │ │ ├── _card_published.html.erb │ │ │ └── _comment.html.erb │ │ ├── index/ │ │ │ ├── _add_board_button.html.erb │ │ │ ├── _add_card_button.html.erb │ │ │ ├── _filter.html.erb │ │ │ └── filter/ │ │ │ ├── _board.html.erb │ │ │ └── _user.html.erb │ │ └── index.html.erb │ ├── filters/ │ │ ├── _filter_toggle.html.erb │ │ ├── _settings.html.erb │ │ ├── create.turbo_stream.erb │ │ ├── destroy.turbo_stream.erb │ │ ├── settings/ │ │ │ ├── _assignees.html.erb │ │ │ ├── _boards.html.erb │ │ │ ├── _cards.html.erb │ │ │ ├── _closers.html.erb │ │ │ ├── _controls.html.erb │ │ │ ├── _creators.html.erb │ │ │ ├── _indexed_by.html.erb │ │ │ ├── _manage.html.erb │ │ │ ├── _sorted_by.html.erb │ │ │ ├── _tags.html.erb │ │ │ ├── _terms.html.erb │ │ │ ├── _time_window.html.erb │ │ │ └── _toggle.html.erb │ │ └── settings_refreshes/ │ │ └── create.turbo_stream.erb │ ├── join_codes/ │ │ ├── inactive.html.erb │ │ └── new.html.erb │ ├── layouts/ │ │ ├── _lightbox.html.erb │ │ ├── _theme_preference.html.erb │ │ ├── action_text/ │ │ │ └── contents/ │ │ │ └── _content.html.erb │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ ├── mailer.text.erb │ │ ├── public.html.erb │ │ └── shared/ │ │ ├── _colophon.html.erb │ │ ├── _flash.html.erb │ │ ├── _head.html.erb │ │ ├── _time_zone.html.erb │ │ ├── _user_css.html.erb │ │ └── _welcome_letter.html.erb │ ├── mailers/ │ │ ├── account_mailer/ │ │ │ ├── cancellation.html.erb │ │ │ └── cancellation.text.erb │ │ ├── export_mailer/ │ │ │ ├── completed.html.erb │ │ │ └── completed.text.erb │ │ ├── identity_mailer/ │ │ │ └── email_change_confirmation.text.erb │ │ ├── import_mailer/ │ │ │ ├── completed.html.erb │ │ │ ├── completed.text.erb │ │ │ ├── failed.html.erb │ │ │ └── failed.text.erb │ │ ├── magic_link_mailer/ │ │ │ ├── sign_in_instructions.html.erb │ │ │ └── sign_in_instructions.text.erb │ │ └── notification/ │ │ └── bundle_mailer/ │ │ ├── _notification.html.erb │ │ ├── _notification.text.erb │ │ ├── event/ │ │ │ ├── _body.html.erb │ │ │ └── _body.text.erb │ │ ├── mention/ │ │ │ ├── _body.html.erb │ │ │ └── _body.text.erb │ │ ├── notification.html.erb │ │ └── notification.text.erb │ ├── my/ │ │ ├── _menu.html.erb │ │ ├── access_tokens/ │ │ │ ├── _access_token.html.erb │ │ │ ├── _access_token.json.jbuilder │ │ │ ├── index.html.erb │ │ │ ├── index.json.jbuilder │ │ │ ├── new.html.erb │ │ │ └── show.html.erb │ │ ├── identities/ │ │ │ ├── _account.json.jbuilder │ │ │ └── show.json.jbuilder │ │ ├── menus/ │ │ │ ├── _accounts.html.erb │ │ │ ├── _boards.html.erb │ │ │ ├── _custom_views.html.erb │ │ │ ├── _jump.html.erb │ │ │ ├── _people.html.erb │ │ │ ├── _settings.html.erb │ │ │ ├── _shortcuts.html.erb │ │ │ ├── _tags.html.erb │ │ │ └── show.html.erb │ │ ├── passkeys/ │ │ │ ├── _passkey.html.erb │ │ │ ├── edit.html.erb │ │ │ └── index.html.erb │ │ └── pins/ │ │ ├── _pin.html.erb │ │ ├── _tray.html.erb │ │ ├── index.html.erb │ │ └── index.json.jbuilder │ ├── notifications/ │ │ ├── _notification.html.erb │ │ ├── _notification.json.jbuilder │ │ ├── _tray.html.erb │ │ ├── index/ │ │ │ ├── _read_notifications.html.erb │ │ │ └── _unread_notifications.html.erb │ │ ├── index.html.erb │ │ ├── index.json.jbuilder │ │ ├── index.turbo_stream.erb │ │ ├── notification/ │ │ │ ├── _body.html.erb │ │ │ ├── _header.html.erb │ │ │ ├── event/ │ │ │ │ ├── _body.html.erb │ │ │ │ └── _body.json.jbuilder │ │ │ └── mention/ │ │ │ ├── _body.html.erb │ │ │ └── _body.json.jbuilder │ │ ├── readings/ │ │ │ ├── create.turbo_stream.erb │ │ │ └── destroy.turbo_stream.erb │ │ ├── settings/ │ │ │ ├── _board.html.erb │ │ │ ├── _browser.html.erb │ │ │ ├── _email.html.erb │ │ │ ├── _install.html.erb │ │ │ ├── _push_notifications.html.erb │ │ │ ├── _system.html.erb │ │ │ ├── show.html.erb │ │ │ └── show.json.jbuilder │ │ ├── trays/ │ │ │ ├── show.html.erb │ │ │ └── show.json.jbuilder │ │ └── unsubscribes/ │ │ ├── new.html.erb │ │ └── show.html.erb │ ├── prompts/ │ │ ├── boards/ │ │ │ └── users/ │ │ │ ├── _user.html.erb │ │ │ └── index.html.erb │ │ ├── cards/ │ │ │ ├── _card.html.erb │ │ │ └── index.html.erb │ │ ├── commands/ │ │ │ ├── _command.html.erb │ │ │ └── index.html.erb │ │ ├── tags/ │ │ │ ├── _tag.html.erb │ │ │ └── index.html.erb │ │ └── users/ │ │ └── index.html.erb │ ├── public/ │ │ ├── _footer.html.erb │ │ ├── boards/ │ │ │ ├── card_previews/ │ │ │ │ └── index.turbo_stream.erb │ │ │ ├── columns/ │ │ │ │ ├── closeds/ │ │ │ │ │ └── show.html.erb │ │ │ │ ├── not_nows/ │ │ │ │ │ └── show.html.erb │ │ │ │ ├── show.html.erb │ │ │ │ └── streams/ │ │ │ │ └── show.html.erb │ │ │ ├── show/ │ │ │ │ ├── _closed.html.erb │ │ │ │ ├── _column.html.erb │ │ │ │ ├── _columns.html.erb │ │ │ │ ├── _not_now.html.erb │ │ │ │ └── _stream.html.erb │ │ │ └── show.html.erb │ │ └── cards/ │ │ ├── show/ │ │ │ ├── _content.html.erb │ │ │ └── _steps.html.erb │ │ └── show.html.erb │ ├── pwa/ │ │ ├── manifest.json.erb │ │ └── service_worker.js.erb │ ├── reactions/ │ │ ├── _menu.html.erb │ │ ├── _reaction.html.erb │ │ ├── _reaction.json.jbuilder │ │ ├── _reactions.html.erb │ │ ├── create.turbo_stream.erb │ │ ├── destroy.turbo_stream.erb │ │ ├── index.html.erb │ │ ├── index.json.jbuilder │ │ ├── new.html.erb │ │ └── show.json.jbuilder │ ├── searches/ │ │ ├── _form.html.erb │ │ ├── _result.html.erb │ │ ├── _results.html.erb │ │ ├── show.html.erb │ │ └── show.json.jbuilder │ ├── sessions/ │ │ ├── _footer.html.erb │ │ ├── magic_links/ │ │ │ └── show.html.erb │ │ ├── menus/ │ │ │ └── show.html.erb │ │ ├── new.html.erb │ │ ├── starts/ │ │ │ └── new.html.erb │ │ └── transfers/ │ │ └── show.html.erb │ ├── signups/ │ │ ├── completions/ │ │ │ └── new.html.erb │ │ └── new.html.erb │ ├── tags/ │ │ ├── _tag.json.jbuilder │ │ ├── index.html.erb │ │ └── index.json.jbuilder │ ├── user_mailer/ │ │ └── email_change_confirmation.html.erb │ ├── users/ │ │ ├── _access_tokens.html.erb │ │ ├── _activity_timeline.html.erb │ │ ├── _attachable.html.erb │ │ ├── _data_export.html.erb │ │ ├── _theme.html.erb │ │ ├── _transfer.html.erb │ │ ├── _user.json.jbuilder │ │ ├── avatars/ │ │ │ └── show.svg.erb │ │ ├── data_exports/ │ │ │ └── show.html.erb │ │ ├── edit.html.erb │ │ ├── email_addresses/ │ │ │ ├── confirmations/ │ │ │ │ ├── invalid_token.html.erb │ │ │ │ └── show.html.erb │ │ │ ├── create.html.erb │ │ │ └── new.html.erb │ │ ├── events/ │ │ │ └── show.html.erb │ │ ├── index.json.jbuilder │ │ ├── joins/ │ │ │ └── new.html.erb │ │ ├── show.html.erb │ │ ├── show.json.jbuilder │ │ └── verifications/ │ │ └── new.html.erb │ └── webhooks/ │ ├── _delivery.html.erb │ ├── _webhook.html.erb │ ├── _webhook.json.jbuilder │ ├── edit.html.erb │ ├── event.html.erb │ ├── event.json.jbuilder │ ├── form/ │ │ └── _actions.html.erb │ ├── index.html.erb │ ├── index.json.jbuilder │ ├── new.html.erb │ ├── show.html.erb │ └── show.json.jbuilder ├── bin/ │ ├── brakeman │ ├── bundle-both │ ├── bundle-drift │ ├── bundler-audit │ ├── ci │ ├── dev │ ├── docker-entrypoint │ ├── gitleaks-audit │ ├── importmap │ ├── jobs │ ├── kamal │ ├── minio-setup │ ├── notify_dash_of_deployment │ ├── rails │ ├── rake │ ├── rubocop │ ├── setup │ └── thrust ├── config/ │ ├── application.rb │ ├── boot.rb │ ├── brakeman.ignore │ ├── cable.yml │ ├── cache.yml │ ├── ci.rb │ ├── database.mysql.yml │ ├── database.sqlite.yml │ ├── database.yml │ ├── deploy.yml │ ├── environment.rb │ ├── environments/ │ │ ├── beta.rb │ │ ├── development.rb │ │ ├── production.rb │ │ ├── staging.rb │ │ └── test.rb │ ├── importmap.rb │ ├── initializers/ │ │ ├── action_text.rb │ │ ├── active_job.rb │ │ ├── active_storage.rb │ │ ├── active_storage_no_reuse.rb │ │ ├── active_storage_purge_on_last_attachment.rb │ │ ├── assets.rb │ │ ├── autotuner.rb │ │ ├── content_security_policy.rb │ │ ├── database_role_logging.rb │ │ ├── error_context.rb │ │ ├── extensions.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mission_control.rb │ │ ├── multi_db.rb │ │ ├── multi_tenant.rb │ │ ├── passkeys.rb │ │ ├── permissions_policy.rb │ │ ├── push_notifications.rb │ │ ├── rack_mini_profiler.rb │ │ ├── sanitization.rb │ │ ├── sqlite_schema_dumper.rb │ │ ├── table_definition_column_limits.rb │ │ ├── tenanting/ │ │ │ ├── account_slug.rb │ │ │ └── turbo.rb │ │ ├── uuid_framework_models.rb │ │ ├── uuid_primary_keys.rb │ │ ├── vapid.rb │ │ ├── vips.rb │ │ └── web_push.rb │ ├── locales/ │ │ └── en.yml │ ├── passkey_aaguids.yml │ ├── puma.rb │ ├── queue.yml │ ├── recurring.yml │ ├── routes.rb │ ├── storage.oss.yml │ └── storage.yml ├── config.ru ├── db/ │ ├── cable_schema.rb │ ├── cache_schema.rb │ ├── migrate/ │ │ ├── 20251111122540_initial_schema.rb │ │ ├── 20251111153019_add_number_to_cards.rb │ │ ├── 20251112093037_create_search_indices.rb │ │ ├── 20251112184932_remove_join_code_from_memberships.rb │ │ ├── 20251113111501_drop_memberships.rb │ │ ├── 20251113160907_add_missing_account_id_columns.rb │ │ ├── 20251113163145_ensure_account_id_index.rb │ │ ├── 20251113190256_create_search_record_shards.rb │ │ ├── 20251114084325_drop_search_results.rb │ │ ├── 20251114183203_ensure_an_identit_can_only_have_one_user_in_an_account.rb │ │ ├── 20251117190817_change_endpoint_to_text_in_push_subscriptions.rb │ │ ├── 20251117192434_change_external_account_id_to_bigint_in_accounts.rb │ │ ├── 20251117202517_change_usage_limit_to_bigint_in_account_join_codes.rb │ │ ├── 20251120110206_add_search_records.rb │ │ ├── 20251120194700_remove_all_foreign_key_constraints.rb │ │ ├── 20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb │ │ ├── 20251121092508_add_account_key_to_search_records.rb │ │ ├── 20251121112416_remove_old_fulltext_indexes_from_search_records.rb │ │ ├── 20251125110629_increase_user_agent_length.rb │ │ ├── 20251125130010_add_a_staff_flag_to_identities.rb │ │ ├── 20251127000001_create_account_external_id_sequences.rb │ │ ├── 20251129110120_add_purpose_to_magic_links.rb │ │ ├── 20251129175717_promote_first_admin_to_owner.rb │ │ ├── 20251201100607_create_account_exports.rb │ │ ├── 20251201132341_create_identity_access_tokens.rb │ │ ├── 20251205010536_add_verified_at_to_users.rb │ │ ├── 20251205205826_create_storage_tables.rb │ │ ├── 20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb │ │ ├── 20251219120755_drop_card_engagements.rb │ │ ├── 20251223000001_rename_account_exports_to_exports.rb │ │ ├── 20251223000002_create_account_imports.rb │ │ ├── 20251224092315_create_account_cancellations.rb │ │ ├── 20260121155752_make_reactions_polymorphic.rb │ │ ├── 20260206104338_add_card_id_to_notifications.rb │ │ ├── 20260209165805_notifications_data_migration.rb │ │ ├── 20260211122517_add_failure_reason_to_account_imports.rb │ │ ├── 20260212102026_fix_notifications_ordered_index.rb │ │ ├── 20260213154740_create_action_pack_passkeys.rb │ │ ├── 20260213170100_add_created_at_index_to_webhook_deliveries.rb │ │ └── 20260218120000_restore_unique_index_on_board_publication_key.rb │ ├── queue_schema.rb │ ├── schema.rb │ ├── schema_sqlite.rb │ ├── seeds/ │ │ ├── 37signals.rb │ │ ├── cleanslate.rb │ │ └── honcho.rb │ └── seeds.rb ├── docs/ │ ├── API.md │ ├── development.md │ ├── docker-deployment.md │ └── kamal-deployment.md ├── lib/ │ ├── action_pack/ │ │ ├── passkey/ │ │ │ ├── challenges_controller.rb │ │ │ ├── form_helper.rb │ │ │ ├── holder.rb │ │ │ └── request.rb │ │ ├── passkey.rb │ │ ├── railtie.rb │ │ ├── web_authn/ │ │ │ ├── authenticator/ │ │ │ │ ├── assertion_response.rb │ │ │ │ ├── attestation.rb │ │ │ │ ├── attestation_response.rb │ │ │ │ ├── attestation_verifiers/ │ │ │ │ │ └── none.rb │ │ │ │ ├── data.rb │ │ │ │ └── response.rb │ │ │ ├── cbor_decoder.rb │ │ │ ├── cose_key.rb │ │ │ ├── current.rb │ │ │ ├── public_key_credential/ │ │ │ │ ├── creation_options.rb │ │ │ │ ├── options.rb │ │ │ │ └── request_options.rb │ │ │ ├── public_key_credential.rb │ │ │ └── relying_party.rb │ │ └── web_authn.rb │ ├── assets/ │ │ └── .keep │ ├── auto_link_scrubber.rb │ ├── deployment/ │ │ └── database_resolver.rb │ ├── deployment.rb │ ├── fizzy.rb │ ├── rails_ext/ │ │ ├── action_mailer_mail_delivery_job.rb │ │ ├── action_pack_passkey_infer_name_from_aaguid.rb │ │ ├── active_record_date_arithmetic.rb │ │ ├── active_record_replica_support.rb │ │ ├── active_record_uuid_type.rb │ │ ├── active_storage_analyze_job_skip_detached.rb │ │ ├── active_storage_analyze_job_suppress_broadcasts.rb │ │ ├── active_storage_authorization.rb │ │ ├── active_storage_blob_service_url_for_direct_upload_expiry.rb │ │ ├── active_support_array_conversions.rb │ │ ├── prepend_order.rb │ │ └── string.rb │ ├── tasks/ │ │ ├── dev.rake │ │ ├── saas.rake │ │ └── search.rake │ └── web_push/ │ ├── notification.rb │ └── pool.rb ├── log/ │ └── .keep ├── public/ │ ├── 400.html │ ├── 404.html │ ├── 406-unsupported-browser.html │ ├── 422.html │ ├── 500.html │ ├── error.css │ └── robots.txt ├── saas/ │ ├── .kamal/ │ │ ├── hooks/ │ │ │ ├── post-deploy │ │ │ └── pre-connect │ │ ├── secrets.beta │ │ ├── secrets.production │ │ └── secrets.staging │ ├── Dockerfile │ ├── LICENSE.md │ ├── README.md │ ├── Rakefile │ ├── app/ │ │ ├── assets/ │ │ │ └── images/ │ │ │ └── fizzy/ │ │ │ └── saas/ │ │ │ └── .keep │ │ ├── controllers/ │ │ │ ├── admin/ │ │ │ │ ├── audits_controller.rb │ │ │ │ └── stats_controller.rb │ │ │ ├── concerns/ │ │ │ │ └── card/ │ │ │ │ ├── storage_limited/ │ │ │ │ │ ├── commenting.rb │ │ │ │ │ ├── creation.rb │ │ │ │ │ └── publishing.rb │ │ │ │ └── storage_limited.rb │ │ │ └── my/ │ │ │ └── devices_controller.rb │ │ ├── jobs/ │ │ │ └── application_push_notification_job.rb │ │ ├── models/ │ │ │ ├── account/ │ │ │ │ ├── storage_exception.rb │ │ │ │ └── storage_limited.rb │ │ │ ├── application_push_device.rb │ │ │ ├── application_push_notification.rb │ │ │ ├── identity/ │ │ │ │ └── devices.rb │ │ │ ├── notification/ │ │ │ │ └── push_target/ │ │ │ │ └── native.rb │ │ │ ├── saas_record.rb │ │ │ ├── session/ │ │ │ │ └── devices.rb │ │ │ └── subscription.rb │ │ └── views/ │ │ ├── admin/ │ │ │ └── stats/ │ │ │ └── show.html.erb │ │ ├── cards/ │ │ │ ├── comments/ │ │ │ │ └── saas/ │ │ │ │ ├── _new.html.erb │ │ │ │ └── _storage_limit_exceeded.html.erb │ │ │ └── container/ │ │ │ └── footer/ │ │ │ └── saas/ │ │ │ ├── _create.html.erb │ │ │ ├── _storage_limit_exceeded.html.erb │ │ │ └── _storage_limit_notice.html.erb │ │ ├── layouts/ │ │ │ └── fizzy/ │ │ │ └── saas/ │ │ │ └── application.html.erb │ │ ├── my/ │ │ │ └── devices/ │ │ │ └── index.html.erb │ │ ├── notifications/ │ │ │ └── settings/ │ │ │ └── _native_devices.html.erb │ │ └── signup/ │ │ ├── completions/ │ │ │ └── new.html.erb │ │ └── new.html.erb │ ├── bin/ │ │ ├── broadcast_to_bc │ │ └── setup │ ├── config/ │ │ ├── database.yml │ │ ├── deploy.beta.yml │ │ ├── deploy.beta1.yml │ │ ├── deploy.beta2.yml │ │ ├── deploy.beta3.yml │ │ ├── deploy.beta4.yml │ │ ├── deploy.production.yml │ │ ├── deploy.staging.yml │ │ ├── deploy.yml │ │ ├── environments/ │ │ │ ├── beta.rb │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── staging.rb │ │ ├── push.yml │ │ ├── routes.rb │ │ └── storage.yml │ ├── db/ │ │ ├── migrate/ │ │ │ ├── 20251202200249_create_console1984_tables.console1984.rb │ │ │ ├── 20251202205753_create_auditing_tables.audits1984.rb │ │ │ ├── 20251203144630_create_account_subscriptions.rb │ │ │ ├── 20251215140000_create_account_overridden_limits.rb │ │ │ ├── 20251215160000_create_account_billing_waivers.rb │ │ │ ├── 20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb │ │ │ ├── 20251216000000_add_bytes_used_to_account_overridden_limits.rb │ │ │ ├── 20260114203313_create_action_push_native_devices.rb │ │ │ ├── 20260126230838_create_auditor_tokens.audits1984.rb │ │ │ ├── 20260317000000_drop_billing_tables.rb │ │ │ └── 20260319142914_create_account_storage_exceptions.rb │ │ └── saas_schema.rb │ ├── exe/ │ │ └── push-dev │ ├── fizzy-saas.gemspec │ ├── lib/ │ │ ├── fizzy/ │ │ │ ├── saas/ │ │ │ │ ├── authorization.rb │ │ │ │ ├── engine.rb │ │ │ │ ├── gvl_instrumentation.rb │ │ │ │ ├── metrics.rb │ │ │ │ ├── signup.rb │ │ │ │ ├── testing.rb │ │ │ │ ├── transaction_pinning.rb │ │ │ │ ├── true_client_ip.rb │ │ │ │ └── version.rb │ │ │ └── saas.rb │ │ ├── rails_ext/ │ │ │ └── active_record_tasks_database_tasks.rb │ │ ├── tasks/ │ │ │ └── fizzy/ │ │ │ └── saas_tasks.rake │ │ └── yabeda/ │ │ ├── gvl.rb │ │ └── solid_queue.rb │ ├── public/ │ │ └── .well-known/ │ │ ├── apple-app-site-association │ │ └── assetlinks.json │ ├── script/ │ │ ├── configure-lb-beta.sh │ │ ├── configure-lb-production.sh │ │ └── configure-lb-staging.sh │ └── test/ │ ├── controllers/ │ │ ├── .keep │ │ ├── admin/ │ │ │ ├── audits_controller_test.rb │ │ │ └── stats_controller_test.rb │ │ ├── card/ │ │ │ ├── storage_limited/ │ │ │ │ ├── commenting_test.rb │ │ │ │ ├── creation_test.rb │ │ │ │ └── publishing_test.rb │ │ │ └── storage_limited_test.rb │ │ ├── comment/ │ │ │ └── storage_limited_test.rb │ │ ├── my/ │ │ │ └── devices_controller_test.rb │ │ └── non_production_remote_access_test.rb │ ├── fixtures/ │ │ ├── application_push_devices.yml │ │ └── files/ │ │ └── .keep │ ├── helpers/ │ │ └── .keep │ ├── integration/ │ │ └── .keep │ ├── lib/ │ │ └── true_client_ip_test.rb │ ├── mailers/ │ │ └── .keep │ └── models/ │ ├── account/ │ │ ├── storage_exception_test.rb │ │ └── storage_limited_test.rb │ ├── identity_test.rb │ ├── notification/ │ │ └── push_target/ │ │ └── native_test.rb │ ├── session/ │ │ └── devices_test.rb │ └── signup_test.rb ├── script/ │ ├── create-identities.rb │ ├── fetch-prod-db.rb │ ├── fix-active-storage-links.rb │ ├── import-sqlite-database.rb │ ├── load-prod-db-in-dev.rb │ ├── maintenance/ │ │ ├── fix_cross_account_taggings.rb │ │ ├── remove_duplicated_search_queries.rb │ │ └── remove_duplicated_tags.rb │ ├── migrations/ │ │ ├── 20250924-populate-identities.rb │ │ ├── 20251028-populate_membership_id_on_users.rb │ │ ├── 20251029-populate-column-positions.rb │ │ ├── 20251205-backfill-verified-at.rb │ │ ├── 20260123-remove-draft-cards-from-search-index.rb │ │ ├── 20260204-fix-misplaced-comment-events.rb │ │ ├── backfill-storage-ledger.rb │ │ ├── convert-absolute-attachment-urls-to-relative.rb │ │ ├── convert-relative-attachment-urls-to-absolute.rb │ │ ├── copy-blobs-to-pure.rb │ │ ├── fill_account_closure_reasons.rb │ │ ├── generate_comments_from_events.rb │ │ ├── migrate-content-to-slugged-urls.rb │ │ ├── migrate-disk-service-blobs.rb │ │ ├── migrate_to_flat_card_urls.rb │ │ ├── migrate_to_new_cards_url_scheme.rb │ │ ├── populate_columns_from_workflow_stages.rb │ │ ├── renaming/ │ │ │ ├── content.rb │ │ │ └── files.rb │ │ ├── reset_boards_ids.rb │ │ ├── reset_cards_ids.rb │ │ └── split-sibling-paragraphs-with-p-br.rb │ ├── populate.rb │ └── remove-lb-admin-production.sh ├── storage/ │ └── .keep ├── test/ │ ├── application_system_test_case.rb │ ├── channels/ │ │ └── application_cable/ │ │ └── connection_test.rb │ ├── controllers/ │ │ ├── account/ │ │ │ └── cancellations_controller_test.rb │ │ ├── accounts/ │ │ │ ├── entropies_controller_test.rb │ │ │ ├── exports_controller_test.rb │ │ │ ├── join_codes_controller_test.rb │ │ │ └── settings_controller_test.rb │ │ ├── active_storage/ │ │ │ └── direct_uploads_controller_test.rb │ │ ├── admin/ │ │ │ └── mission_control_test.rb │ │ ├── allow_browser_test.rb │ │ ├── api/ │ │ │ └── flat_json_params_test.rb │ │ ├── api_test.rb │ │ ├── boards/ │ │ │ ├── columns/ │ │ │ │ ├── closeds_controller_test.rb │ │ │ │ ├── not_nows_controller_test.rb │ │ │ │ └── streams_controller_test.rb │ │ │ ├── columns_controller_test.rb │ │ │ ├── entropies_controller_test.rb │ │ │ ├── involvements_controller_test.rb │ │ │ └── publications_controller_test.rb │ │ ├── boards_controller_test.rb │ │ ├── cards/ │ │ │ ├── assignments_controller_test.rb │ │ │ ├── boards_controller_test.rb │ │ │ ├── closures_controller_test.rb │ │ │ ├── comments/ │ │ │ │ └── reactions_controller_test.rb │ │ │ ├── comments_controller_test.rb │ │ │ ├── drafts_controller_test.rb │ │ │ ├── goldnesses_controller_test.rb │ │ │ ├── images_controller_test.rb │ │ │ ├── not_nows_controller_test.rb │ │ │ ├── pins_controller_test.rb │ │ │ ├── previews_controller_test.rb │ │ │ ├── publishes_controller_test.rb │ │ │ ├── reactions_controller_test.rb │ │ │ ├── readings_controller_test.rb │ │ │ ├── self_assignments_controller_test.rb │ │ │ ├── steps_controller_test.rb │ │ │ ├── taggings_controller_test.rb │ │ │ ├── triages_controller_test.rb │ │ │ └── watches_controller_test.rb │ │ ├── cards_controller_test.rb │ │ ├── client_configurations_controller_test.rb │ │ ├── columns/ │ │ │ ├── cards/ │ │ │ │ └── drops/ │ │ │ │ ├── closures_controller_test.rb │ │ │ │ ├── columns_controller_test.rb │ │ │ │ ├── not_nows_controller_test.rb │ │ │ │ └── streams_controller_test.rb │ │ │ ├── left_positions_controller_test.rb │ │ │ └── right_positions_controller_test.rb │ │ ├── concerns/ │ │ │ ├── block_search_engine_indexing_test.rb │ │ │ ├── current_timezone_test.rb │ │ │ ├── request_forgery_protection_test.rb │ │ │ └── set_platform_test.rb │ │ ├── controller_authentication_test.rb │ │ ├── events/ │ │ │ └── day_timeline/ │ │ │ └── columns_controller_test.rb │ │ ├── events_controller_test.rb │ │ ├── filters_controller_test.rb │ │ ├── join_codes_controller_test.rb │ │ ├── landings_controller_test.rb │ │ ├── my/ │ │ │ ├── access_tokens_controller_test.rb │ │ │ ├── identities_controller_test.rb │ │ │ ├── menus_controller_test.rb │ │ │ ├── passkey_challenges_controller_test.rb │ │ │ ├── passkeys_controller_test.rb │ │ │ ├── pins_controller_test.rb │ │ │ └── timezones_controller_test.rb │ │ ├── notifications/ │ │ │ ├── bulk_readings_controller_test.rb │ │ │ ├── readings_controller_test.rb │ │ │ ├── settings_controller_test.rb │ │ │ ├── trays_controller_test.rb │ │ │ └── unsubscribes_controller_test.rb │ │ ├── notifications_controller_test.rb │ │ ├── prompts/ │ │ │ ├── boards/ │ │ │ │ └── users_controller_test.rb │ │ │ ├── cards_controller_test.rb │ │ │ ├── tags_controller_test.rb │ │ │ └── users_controller_test.rb │ │ ├── public/ │ │ │ ├── boards/ │ │ │ │ ├── columns/ │ │ │ │ │ ├── closeds_controller_test.rb │ │ │ │ │ ├── not_nows_controller_test.rb │ │ │ │ │ └── streams_controller_test.rb │ │ │ │ └── columns_controller_test.rb │ │ │ ├── boards_controller_test.rb │ │ │ └── cards_controller_test.rb │ │ ├── qr_codes_controller_test.rb │ │ ├── searches/ │ │ │ └── queries_controller_test.rb │ │ ├── searches_controller_test.rb │ │ ├── sessions/ │ │ │ ├── magic_links_controller_test.rb │ │ │ ├── menus_controller_test.rb │ │ │ ├── passkeys_controller_test.rb │ │ │ └── transfers_controller_test.rb │ │ ├── sessions_controller_test.rb │ │ ├── signup/ │ │ │ └── completions_controller_test.rb │ │ ├── signups_controller_test.rb │ │ ├── tags_controller_test.rb │ │ ├── users/ │ │ │ ├── avatars_controller_test.rb │ │ │ ├── data_exports_controller_test.rb │ │ │ ├── email_addresses/ │ │ │ │ └── confirmations_controller_test.rb │ │ │ ├── email_addresses_controller_test.rb │ │ │ ├── events_controller_test.rb │ │ │ ├── joins_controller_test.rb │ │ │ ├── push_subscriptions_controller_test.rb │ │ │ ├── roles_controller_test.rb │ │ │ └── verifications_controller_test.rb │ │ ├── users_controller_test.rb │ │ ├── webhooks/ │ │ │ └── activations_controller_test.rb │ │ └── webhooks_controller_test.rb │ ├── fixtures/ │ │ ├── accesses.yml │ │ ├── account/ │ │ │ └── join_codes.yml │ │ ├── accounts.yml │ │ ├── action_text/ │ │ │ └── rich_texts.yml │ │ ├── assignees_filters.yml │ │ ├── assignments.yml │ │ ├── boards.yml │ │ ├── card/ │ │ │ └── goldnesses.yml │ │ ├── cards.yml │ │ ├── closures.yml │ │ ├── columns.yml │ │ ├── comments.yml │ │ ├── entropies.yml │ │ ├── events.yml │ │ ├── exports.yml │ │ ├── filters.yml │ │ ├── filters_tags.yml │ │ ├── identities.yml │ │ ├── identity/ │ │ │ └── access_tokens.yml │ │ ├── mentions.yml │ │ ├── notifications.yml │ │ ├── pins.yml │ │ ├── reactions.yml │ │ ├── sessions.yml │ │ ├── taggings.yml │ │ ├── tags.yml │ │ ├── user/ │ │ │ └── settings.yml │ │ ├── users.yml │ │ ├── watches.yml │ │ ├── webhook/ │ │ │ ├── delinquency_trackers.yml │ │ │ └── deliveries.yml │ │ └── webhooks.yml │ ├── helpers/ │ │ ├── .keep │ │ ├── action_text_rendering_test.rb │ │ ├── application_helper_test.rb │ │ ├── entropy_helper_test.rb │ │ ├── excerpt_helper_test.rb │ │ ├── hotkeys_helper_test.rb │ │ └── html_helper_test.rb │ ├── integration/ │ │ ├── active_storage_authorization_test.rb │ │ ├── blob_key_traversal_test.rb │ │ ├── card_preview_boost_count_test.rb │ │ └── notifications_test.rb │ ├── jobs/ │ │ ├── account/ │ │ │ ├── data_import_job_test.rb │ │ │ └── incinerate_due_job_test.rb │ │ ├── delete_unused_tags_job_test.rb │ │ └── storage/ │ │ ├── materialize_job_test.rb │ │ └── reconcile_job_test.rb │ ├── lib/ │ │ ├── action_pack/ │ │ │ ├── passkey_test.rb │ │ │ └── web_authn/ │ │ │ ├── authenticator/ │ │ │ │ ├── assertion_response_test.rb │ │ │ │ ├── attestation_response_test.rb │ │ │ │ ├── attestation_test.rb │ │ │ │ ├── attestation_verifiers/ │ │ │ │ │ └── none_test.rb │ │ │ │ ├── data_test.rb │ │ │ │ └── response_test.rb │ │ │ ├── cbor_decoder_test.rb │ │ │ ├── cose_key_test.rb │ │ │ ├── public_key_credential/ │ │ │ │ ├── creation_options_test.rb │ │ │ │ └── request_options_test.rb │ │ │ └── relying_party_test.rb │ │ ├── rails_ext/ │ │ │ ├── action_pack_passkey_infer_name_from_aaguid_test.rb │ │ │ ├── active_record_uuid_type_test.rb │ │ │ ├── active_storage_analyze_job_skip_detached_test.rb │ │ │ ├── active_storage_blob_service_url_for_direct_upload_expiry_test.rb │ │ │ └── string_test.rb │ │ └── web_push/ │ │ └── persistent_request_test.rb │ ├── mailers/ │ │ ├── .keep │ │ ├── account_mailer_test.rb │ │ ├── export_mailer_test.rb │ │ ├── import_mailer_test.rb │ │ ├── magic_link_mailer_test.rb │ │ ├── notification/ │ │ │ └── bundle_mailer_test.rb │ │ ├── previews/ │ │ │ ├── export_mailer_preview.rb │ │ │ ├── magic_link_mailer_preview.rb │ │ │ ├── notification/ │ │ │ │ └── bundle_mailer_preview.rb │ │ │ └── user_mailer_preview.rb │ │ └── smtp_delivery_error_test.rb │ ├── middleware/ │ │ └── account_slug_extractor_test.rb │ ├── models/ │ │ ├── access_test.rb │ │ ├── account/ │ │ │ ├── cancellable_test.rb │ │ │ ├── cancellation_test.rb │ │ │ ├── data_transfer/ │ │ │ │ ├── action_text/ │ │ │ │ │ └── rich_text_record_set_test.rb │ │ │ │ ├── active_storage/ │ │ │ │ │ ├── blob_record_set_test.rb │ │ │ │ │ └── file_record_set_test.rb │ │ │ │ └── record_set_test.rb │ │ │ ├── export_test.rb │ │ │ ├── external_id_sequence_test.rb │ │ │ ├── import_test.rb │ │ │ ├── incineratable_test.rb │ │ │ ├── join_code_test.rb │ │ │ ├── multi_tenantable_test.rb │ │ │ └── seedeable_test.rb │ │ ├── account_test.rb │ │ ├── application_platform_test.rb │ │ ├── assignment_test.rb │ │ ├── board/ │ │ │ ├── accessible_test.rb │ │ │ ├── cards_test.rb │ │ │ └── publishable_test.rb │ │ ├── card/ │ │ │ ├── activity_spike/ │ │ │ │ └── detector_test.rb │ │ │ ├── assignable_test.rb │ │ │ ├── closeable_test.rb │ │ │ ├── colored_test.rb │ │ │ ├── commentable_test.rb │ │ │ ├── entropic_test.rb │ │ │ ├── eventable/ │ │ │ │ └── system_commenter_test.rb │ │ │ ├── eventable_test.rb │ │ │ ├── exportable_test.rb │ │ │ ├── golden_test.rb │ │ │ ├── messages_test.rb │ │ │ ├── pinnable_test.rb │ │ │ ├── postponable_test.rb │ │ │ ├── readable_test.rb │ │ │ ├── searchable_test.rb │ │ │ ├── stallable_test.rb │ │ │ ├── statuses_test.rb │ │ │ ├── taggable_test.rb │ │ │ ├── triageable_test.rb │ │ │ └── watchable_test.rb │ │ ├── card_test.rb │ │ ├── column/ │ │ │ ├── colored_test.rb │ │ │ └── positioned_test.rb │ │ ├── column_limits_test.rb │ │ ├── column_test.rb │ │ ├── comment/ │ │ │ └── searchable_test.rb │ │ ├── comment_test.rb │ │ ├── concerns/ │ │ │ └── mentions_test.rb │ │ ├── entropy_test.rb │ │ ├── event/ │ │ │ └── description_test.rb │ │ ├── filter/ │ │ │ └── search_test.rb │ │ ├── filter_test.rb │ │ ├── identity/ │ │ │ ├── access_token_test.rb │ │ │ ├── joinable_test.rb │ │ │ └── transferable_test.rb │ │ ├── identity_test.rb │ │ ├── magic_link/ │ │ │ └── code_test.rb │ │ ├── magic_link_test.rb │ │ ├── notification/ │ │ │ ├── bundle_test.rb │ │ │ ├── push_target/ │ │ │ │ └── web_test.rb │ │ │ └── pushable_test.rb │ │ ├── notification_test.rb │ │ ├── notifier/ │ │ │ ├── event_notifier_test.rb │ │ │ └── mention_notifier_test.rb │ │ ├── push/ │ │ │ └── subscription_test.rb │ │ ├── qr_code_link_test.rb │ │ ├── reaction_test.rb │ │ ├── search/ │ │ │ ├── highlighter_test.rb │ │ │ └── stemmer_test.rb │ │ ├── search_test.rb │ │ ├── signup/ │ │ │ └── account_name_generator_test.rb │ │ ├── signup_test.rb │ │ ├── ssrf_protection_test.rb │ │ ├── storage/ │ │ │ ├── attachment_tracking_test.rb │ │ │ ├── entry_test.rb │ │ │ ├── no_reuse_test.rb │ │ │ ├── total_test.rb │ │ │ ├── totaled_test.rb │ │ │ └── tracked_test.rb │ │ ├── tag_test.rb │ │ ├── time_window_parser_test.rb │ │ ├── user/ │ │ │ ├── accessor_test.rb │ │ │ ├── avatar_test.rb │ │ │ ├── configurable_test.rb │ │ │ ├── data_export_test.rb │ │ │ ├── email_address_changeable_test.rb │ │ │ ├── mentionable_test.rb │ │ │ ├── named_test.rb │ │ │ ├── notifiable_test.rb │ │ │ ├── role_test.rb │ │ │ ├── searcher_test.rb │ │ │ └── settings_test.rb │ │ ├── user_test.rb │ │ ├── webhook/ │ │ │ ├── delinquency_tracker_test.rb │ │ │ ├── delivery_test.rb │ │ │ └── triggerable_test.rb │ │ ├── webhook_test.rb │ │ └── zip_file_test.rb │ ├── routes_test.rb │ ├── system/ │ │ ├── .keep │ │ ├── back_link_navigation_test.rb │ │ ├── markdown_paste_test.rb │ │ └── smoke_test.rb │ ├── test_helper.rb │ ├── test_helpers/ │ │ ├── action_text_test_helper.rb │ │ ├── caching_test_helper.rb │ │ ├── card_activity_test_helper.rb │ │ ├── card_test_helper.rb │ │ ├── change_test_helper.rb │ │ ├── command_test_helper.rb │ │ ├── search_test_helper.rb │ │ ├── session_test_helper.rb │ │ ├── vcr_test_helper.rb │ │ └── webauthn_test_helper.rb │ └── webmock_ipaddr_extension.rb ├── tmp/ │ └── .keep └── vendor/ └── javascript/ ├── @hotwired--hotwire-native-bridge.js └── @rails--request.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/CLAUDE.md ================================================ @../AGENTS.md ================================================ FILE: .dockerignore ================================================ # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. # Ignore git directory. /.git/ # Ignore bundler config. /.bundle # Ignore documentation /docs/ /README.md /CLAUDE.md /AGENTS.md /STYLE.md /CONTRIBUTING.md # Ignore all environment files (except templates). /.env* !/.env*.erb # Ignore all default key files. /config/master.key /config/credentials/*.key # Ignore all logfiles and tempfiles. /log/* /tmp/* !/log/.keep !/tmp/.keep # Ignore pidfiles, but keep the directory. /tmp/pids/* !/tmp/pids/.keep # Ignore storage (uploaded files in development and any SQLite databases). /storage/* !/storage/.keep /tmp/storage/* !/tmp/storage/.keep # Ignore assets. /node_modules/ /app/assets/builds/* !/app/assets/builds/.keep /public/assets ================================================ FILE: .gitattributes ================================================ # See https://git-scm.com/docs/gitattributes for more about git attribute files. # Mark the database schema as having been generated. db/schema.rb linguist-generated # Mark any vendored files as having been vendored. vendor/* linguist-vendored ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Features, Bug Reports, Questions url: https://github.com/basecamp/fizzy/discussions/new/choose about: Please use the discussions area to report issues or ask quest ================================================ FILE: .github/ISSUE_TEMPLATE/preapproved.md ================================================ --- name: Pre-Discussed and Approved Topics about: |- For topics already discussed and approved in the GitHub Discussions section. --- ** PLEASE START A DISCUSSION INSTEAD OF OPENING AN ISSUE ** ** For more details see CONTRIBUTING.md ** ================================================ FILE: .github/dependabot.yml ================================================ version: 2 registries: github-basecamp: type: git url: https://github.com username: x-access-token password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler registries: - github-basecamp directory: "/" insecure-external-code-execution: allow # zizmor: ignore[dependabot-execution] -- required for Bundler to resolve gems from the private github-basecamp registry open-pull-requests-limit: 10 vendor: false groups: development-dependencies: dependency-type: "development" schedule: interval: weekly cooldown: default-days: 7 semver-major-days: 14 - package-ecosystem: github-actions directory: "/" groups: github-actions: patterns: - "*" schedule: interval: weekly open-pull-requests-limit: 10 cooldown: default-days: 7 ================================================ FILE: .github/workflows/ci-checks.yml ================================================ name: Checks on: pull_request: permissions: contents: read jobs: gemfile-drift: name: Gemfile drift runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true - name: Check for lockfile drift run: bin/bundle-drift check security: name: Security runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true - name: Gem audit run: bin/bundler-audit check --update - name: Importmap audit run: bin/importmap audit - name: Brakeman audit run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true - name: Lint code for consistent style run: bin/rubocop zizmor: name: GitHub Actions audit runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run zizmor uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 with: advanced-security: false ================================================ FILE: .github/workflows/ci-oss.yml ================================================ name: CI (OSS) on: pull_request: types: [opened, synchronize] permissions: contents: read jobs: test: if: github.event.pull_request.head.repo.full_name != github.repository uses: ./.github/workflows/test.yml with: saas: false ================================================ FILE: .github/workflows/ci-saas.yml ================================================ name: CI (SaaS) on: push: permissions: contents: read jobs: test_oss: name: Test (OSS) uses: ./.github/workflows/test.yml with: saas: false test_saas: name: Test (SaaS) uses: ./.github/workflows/test.yml with: saas: true secrets: FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} ================================================ FILE: .github/workflows/dependabot-sync-saas-lockfile.yml ================================================ name: Sync Gemfile.saas.lock on: push: branches: - "dependabot/bundler/**" paths: - Gemfile.lock permissions: contents: write jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] -- credentials needed for git push - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version - name: Forward Gemfile.lock changes to Gemfile.saas.lock run: bin/bundle-drift forward - name: Commit updated lockfile run: | git add Gemfile.saas.lock if ! git diff --cached --quiet; then git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "Sync Gemfile.saas.lock" git push fi ================================================ FILE: .github/workflows/publish-image.yml ================================================ name: Build and publish container image to GHCR on: push: branches: - main tags: - 'v*' workflow_dispatch: concurrency: group: publish-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: IMAGE_DESCRIPTION: Fizzy is Kanban as it should be. Not as it has been. SOURCE_URL: https://github.com/${{ github.repository }} jobs: build: name: Build and push image (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} timeout-minutes: 45 permissions: contents: read packages: write id-token: write attestations: write strategy: fail-fast: false matrix: include: - runner: ubuntu-latest platform: linux/amd64 arch: amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Compute canonical image name (lowercase) id: vars shell: bash run: | set -eu IMAGE_REF="${IMAGE_NAME:-$GITHUB_REPOSITORY}" CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" - name: Extract Docker metadata (tags, labels) with arch suffix id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | type=ref,event=branch type=ref,event=tag type=sha,format=short,prefix=sha- type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} flavor: | latest=false suffix=-${{ matrix.arch }} labels: | org.opencontainers.image.source=${{ env.SOURCE_URL }} - name: Build and push (${{ matrix.platform }}) id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . file: Dockerfile build-args: | OCI_SOURCE=${{ env.SOURCE_URL }} OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }} platforms: ${{ matrix.platform }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,scope=${{ matrix.platform }},mode=max sbom: false provenance: false - name: Attest image provenance (per-arch) if: github.event_name != 'pull_request' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ steps.vars.outputs.canonical }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: false manifest: name: Create multi-arch manifest and sign needs: build if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 20 permissions: packages: write id-token: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - name: Set up Docker Buildx (for imagetools) uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Compute canonical image name (lowercase) id: vars shell: bash run: | set -eu IMAGE_REF="${IMAGE_NAME:-$GITHUB_REPOSITORY}" CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" - name: Compute base tags (no suffix) id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | type=ref,event=branch type=ref,event=tag type=sha,format=short,prefix=sha- type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} flavor: | latest=false labels: | org.opencontainers.image.source=${{ env.SOURCE_URL }} - name: Create multi-arch manifests shell: bash env: TAGS: ${{ steps.meta.outputs.tags }} run: | set -eu tags="$TAGS" echo "Creating manifests for tags:" printf '%s\n' "$tags" while IFS= read -r tag; do [ -z "$tag" ] && continue echo "Creating manifest for $tag" src_tag="$tag" if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then ref="${GITHUB_REF#refs/tags/}" src_tag="${tag%:latest}:$ref" fi if [ -n "${IMAGE_DESCRIPTION:-}" ]; then docker buildx imagetools create \ --tag "$tag" \ --annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \ "${src_tag}-amd64" \ "${src_tag}-arm64" else docker buildx imagetools create \ --tag "$tag" \ "${src_tag}-amd64" \ "${src_tag}-arm64" fi done <<< "$tags" - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Cosign sign all tags (keyless OIDC) shell: bash env: TAGS: ${{ steps.meta.outputs.tags }} run: | set -eu tags="$TAGS" printf '%s\n' "$tags" while IFS= read -r tag; do [ -z "$tag" ] && continue echo "Signing $tag" cosign sign --yes "$tag" done <<< "$tags" ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: workflow_call: inputs: saas: type: boolean required: true secrets: FIZZY_GH_TOKEN: required: false permissions: contents: read jobs: test: name: Tests (${{ matrix.mode }}) runs-on: ubuntu-latest strategy: matrix: include: - mode: SQLite db_adapter: sqlite - mode: MySQL db_adapter: mysql services: mysql: image: mysql:8.0 env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: fizzy_test ports: - 3306:3306 options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 env: RAILS_ENV: test DATABASE_ADAPTER: ${{ matrix.db_adapter }} ${{ inputs.saas && 'SAAS' || 'SAAS_DISABLED' }}: ${{ inputs.saas && '1' || '' }} BUNDLE_GEMFILE: ${{ inputs.saas && 'Gemfile.saas' || 'Gemfile' }} MYSQL_HOST: 127.0.0.1 MYSQL_PORT: 3306 MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} # zizmor: ignore[secrets-outside-env] steps: - name: Install system packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true - name: Run tests run: bin/rails db:setup test - name: Run system tests run: bin/rails test:system ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files for more about ignoring files. # # If you find yourself ignoring temporary files generated by your text editor # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile '~/.gitignore_global' # Ignore bundler config. /.bundle # Ignore all environment files (except templates). /.env* !/.env*.erb # Ignore all logfiles and tempfiles. /log/* /tmp/* !/log/.keep !/tmp/.keep *.log # Ignore pidfiles, but keep the directory. /tmp/pids/* !/tmp/pids/ !/tmp/pids/.keep # Ignore storage (uploaded files in development and any SQLite databases). /storage/* !/storage/.keep /tmp/storage/* !/tmp/storage/ !/tmp/storage/.keep /data *.sqlite3 *.sqlite3_* /public/assets # Ignore master key for decrypting credentials and more. /config/master.key /config/credentials/*.key .DS_Store ================================================ FILE: .gitleaks.toml ================================================ [extend] useDefault = true [allowlist] paths = [ '''log''', '''tmp''', '''.*\.yml\.enc''', '''docs/''', '''test/''', ] [[rules]] id = "basecamp-integration-url" description = "Basecamp Integration URL" regex = '''https://[^\s]*?([0-9a-fA-F]{16,})''' [rules.allowlist] regexTarget = "match" regexes = ['''github\.com'''] ================================================ FILE: .gitleaksignore ================================================ d8463077:gems/fizzy-saas/bin/setup:generic-api-key:54 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:3 c4073c1c:app/models/integration/basecamp.rb:generic-api-key:4 ================================================ FILE: .mise.toml ================================================ [settings] idiomatic_version_file_enable_tools = ["ruby"] [env] PROMETHEUS_EXPORTER_URL = "http://127.0.0.1:9306/metrics" ================================================ FILE: .rubocop.yml ================================================ # Omakase Ruby styling for Rails inherit_gem: { rubocop-rails-omakase: rubocop.yml } # Overwrite or add rules to create your own house style # # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false AllCops: Exclude: - 'db/migrate/**/*' - 'db/schema*.rb' - 'saas/db/migrate/**/*' - 'saas/db/saas_schema.rb' ================================================ FILE: .ruby-version ================================================ 3.4.7 ================================================ FILE: AGENTS.md ================================================ # Fizzy This file provides guidance to AI coding agents working with 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@example.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) ``` ## Deploy Default branch: `main` Pre-deploy: `bin/rails saas:enable` Deploy: `bin/kamal deploy -d ` Destinations: production, staging, beta, beta1, beta2, beta3, beta4 Note: `beta` is a template requiring `BETA_NUMBER` env var; typical targets are `beta1`-`beta4`. ## 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: owner, 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 (owner/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/` ### Imports and exports Allow people to move between OSS and SAAS Fizzy instances: - Exports/Imports can be written to/read from local or S3 storage depending on the config of the instance (both must be supported) - Must be able to handle very large ZIP files (500+GB) - Models in `app/models/account/data_transfer/`, `app/models/zip_file` ## Tools ### Chrome MCP (Local Dev) URL: `http://fizzy.localhost:3006` Login: david@example.com (passwordless magic link auth - check rails console for link) Use Chrome MCP tools to interact with the running dev app for UI testing and debugging. ## Coding style @STYLE.md ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute to Fizzy Fizzy uses GitHub [discussions](https://github.com/basecamp/fizzy/discussions) to track feature requests and questions, rather than [the issue tracker](https://github.com/basecamp/fizzy/issues). If you're considering opening an issue or pull request, please open a discussion instead. Whenever a discussion leads to an actionable and well-understood task, we'll move it to the issue tracker where it can be worked on. This is a little different than how some other projects work, but it makes it easier for us to triage and prioritise the work. It also means that the open issues all represent agreed-upon tasks that are either being worked on, or are ready to be worked on. This should also make it easier to see what's in progress, and to find something to work on if you'd like to do so. ## What this means in practice ### If you'd like to contribute to the code... 1. If you're interested in working on one of the open issues, please do! We are grateful for the help! 2. You'll want to make sure someone else isn't already working on the same issue. If they are, it will be tagged "in progress" and/or it should be clear from the comments. When in doubt, you can always comment on the issue to ask. 3. Similarly, if you need any help or guidance on the issue, please comment on the issue as you go, and we'll do our best to help. 4. When you have something ready for review or collaboration, open a PR. ### If you've found a bug... 1. If you don't have steps to reproduce the problem, or you're not certain it's a bug, open a discussion. 2. If you have steps to reproduce, open an issue. ### If you have an idea for a feature... 1. Open a discussion. ### If you have a question, or are having trouble with configuration... 1. Open a discussion. Hopefully this process makes it easier for everyone to be involved. Thanks for helping! ❤️ ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 # check=error=true # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: # docker build -t fizzy . # docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name fizzy fizzy # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.7 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails # Install base packages RUN apt-get update -qq && \ apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libssl-dev && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment variables and enable jemalloc for reduced memory usage and latency. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development:test" \ LD_PRELOAD="/usr/local/lib/libjemalloc.so" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems COPY Gemfile Gemfile.lock vendor ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 bundle exec bootsnap precompile -j 1 --gemfile # Copy application code COPY . . # Precompile bootsnap code for faster boot times. # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 RUN bundle exec bootsnap precompile -j 1 app/ lib/ # Precompiling assets for production without requiring secret RAILS_MASTER_KEY RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image FROM base # Image metadata ARG OCI_DESCRIPTION LABEL org.opencontainers.image.description="${OCI_DESCRIPTION}" ARG OCI_SOURCE LABEL org.opencontainers.image.source="${OCI_SOURCE}" LABEL org.opencontainers.image.licenses="O'Saasy" # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash USER 1000:1000 # Copy built artifacts: gems, application COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] # Start server via Thruster by default, this can be overwritten at runtime EXPOSE 80 CMD ["./bin/thrust", "./bin/rails", "server"] ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "rails", github: "rails/rails", branch: "main" # Assets & front end gem "importmap-rails" gem "propshaft" gem "stimulus-rails" gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache" # Deployment and drivers gem "bootsnap", require: false gem "kamal", require: false gem "puma", ">= 5.0" gem "solid_cable", ">= 3.0" gem "solid_cache", "~> 1.0" gem "solid_queue", "~> 1.3" gem "sqlite3", ">= 2.0" gem "thruster", require: false gem "trilogy", "~> 2.10" # Features gem "bcrypt", "~> 3.1.7" gem "geared_pagination", "~> 1.2" gem "rqrcode" gem "rouge" gem "jbuilder" gem "lexxy", "0.9.0.beta" gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" gem "zip_kit" gem "mittens" gem "useragent", bc: "useragent" # Operations gem "autotuner" gem "mission_control-jobs" gem "stackprof" gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings group :development, :test do gem "brakeman", require: false gem "bundler-audit", require: false gem "debug" gem "faker" gem "letter_opener" gem "rack-mini-profiler" gem "rubocop-rails-omakase", require: false end group :development do gem "web-console", github: "rails/web-console" end group :test do gem "capybara" gem "selenium-webdriver" gem "webmock" gem "vcr" gem "mocha" end ================================================ FILE: Gemfile.saas ================================================ # This Gemfile extends the base Gemfile with SaaS-specific dependencies eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api" # Native push notifications (iOS/Android) gem "action_push_native" # Telemetry gem "rails_structured_logging", bc: "rails-structured-logging" gem "sentry-ruby" gem "sentry-rails" gem "yabeda" gem "yabeda-actioncable" gem "yabeda-activejob", github: "basecamp/yabeda-activejob", branch: "bulk-and-scheduled-jobs" gem "yabeda-gc" gem "yabeda-http_requests" gem "yabeda-prometheus-mmap" gem "yabeda-puma-plugin" gem "yabeda-rails" gem "webrick" # required for yabeda-prometheus metrics server gem "prometheus-client-mmap", "~> 1.3" gem "gvltools" ================================================ FILE: LICENSE.md ================================================ # O'Saasy License Agreement Copyright © 2025, 37signals LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Fizzy This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com). ## Running your own Fizzy instance If you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image. You'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation. You can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md). If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md). ## Development You are welcome -- and encouraged -- to modify Fizzy to your liking. Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development. ## Contributing We welcome contributions! Please read our [style guide](STYLE.md) before submitting code. ## License Fizzy is released under the [O'Saasy License](LICENSE.md). ================================================ FILE: Rakefile ================================================ # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. require_relative 'config/application' Rails.application.load_tasks ================================================ FILE: STYLE.md ================================================ # 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 module SomeModule private def some_private_method # ... end end ``` ## CRUD controllers We model web endpoints as CRUD operations on resources (REST). When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions. ```ruby # Bad resources :cards do post :close post :reopen end # Good resources :cards do resource :closure end ``` ## Controller and model interactions In general, we favor a [vanilla Rails](https://dev.37signals.com/vanilla-rails-is-plenty/) approach with thin controllers directly invoking a rich domain model. We don't use services or other artifacts to connect the two. Invoking plain Active Record operations is totally fine: ```ruby class Cards::CommentsController < ApplicationController def create @comment = @card.comments.create!(comment_params) end end ``` For more complex behavior, we prefer clear, intention-revealing model APIs that controllers call directly: ```ruby class Cards::GoldnessesController < ApplicationController def create @card.gild end end ``` When justified, it is fine to use services or form objects, but don't treat those as special artifacts: ```ruby Signup.new(email_address: email_address).create_identity ``` ## 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 ``` ================================================ FILE: app/assets/images/.keep ================================================ ================================================ FILE: app/assets/stylesheets/_global.css ================================================ @layer reset, base, components, modules, utilities, native, platform; :root { /* Insets - The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); /* Spacing */ --inline-space: 1ch; --inline-space-half: calc(var(--inline-space) / 2); --inline-space-double: calc(var(--inline-space) * 2); --block-space: 1rem; --block-space-half: calc(var(--block-space) / 2); --block-space-double: calc(var(--block-space) * 2); /* Text */ --font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI Variable Fizzy", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; --text-xx-small: 0.55rem; --text-x-small: 0.75rem; --text-small: 0.85rem; --text-normal: 1rem; --text-medium: 1.1rem; --text-large: 1.5rem; --text-x-large: 1.8rem; --text-xx-large: 2.5rem; @media (max-width: 639px) { --text-xx-small: 0.65rem; --text-x-small: 0.85rem; --text-small: 0.95rem; --text-normal: 1.1rem; --text-medium: 1.2rem; --text-large: 1.5rem; --text-x-large: 1.8rem; --text-xx-large: 2.5rem; } /* Borders */ --border: 1px solid var(--color-ink-lighter); /* Shadows */ --shadow: 0 0 0 1px oklch(var(--lch-black) / 5%), 0 0.2em 0.2em oklch(var(--lch-black) / 5%), 0 0.4em 0.4em oklch(var(--lch-black) / 5%), 0 0.8em 0.8em oklch(var(--lch-black) / 5%); /* Components */ --btn-size: 2.65em; --footer-height: 2.65rem; --tray-size: clamp(12rem, 25dvw, 24rem); /* Focus rings for keyboard navigation */ --focus-ring-color: var(--color-link); --focus-ring-offset: 1px; --focus-ring-size: 2px; --focus-ring: var(--focus-ring-size) solid var(--focus-ring-color); /* Dialogs */ --dialog-duration: 150ms; /* Easing functions from https://easingwizard.com/ */ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); --ease-out-overshoot: cubic-bezier(0.25, 1.75, 0.5, 1); --ease-out-overshoot-subtle: cubic-bezier(0.25, 1.25, 0.5, 1); @media (max-width: 799px) { --tray-size: var(--footer-height); } /* Layout */ --main-padding: clamp(var(--inline-space), 3vw, calc(var(--inline-space) * 3)); --main-width: 1400px; /* Z-index */ --z-events-column-header: 1; --z-events-day-header: 3; --z-popup: 10; --z-nav: 20; --z-flash: 30; --z-tooltip: 40; --z-bar: 50; --z-tray: 51; --z-welcome: 52; --z-nav-open: 100; /* OKLCH colors: Fixed */ --lch-black: 0% 0 0; --lch-white: 100% 0 0; /* OKLCH colors: Light mode */ --lch-canvas: var(--lch-white); --lch-ink-inverted: var(--lch-white); --lch-ink-darkest: 26% 0.05 264; --lch-ink-darker: 40% 0.026 262; --lch-ink-dark: 56% 0.014 260; --lch-ink-medium: 66% 0.008 258; --lch-ink-light: 84% 0.005 256; --lch-ink-lighter: 92% 0.003 254; --lch-ink-lightest: 96% 0.002 252; --lch-uncolor-darkest: 26% 0.018 40; --lch-uncolor-darker: 40.04% 0.0376 50.06; --lch-uncolor-dark: 57.09% 0.0676 60.5; --lch-uncolor-medium: 66% 0.0944 71.46; --lch-uncolor-light: 83.97% 0.0457 80.84; --lch-uncolor-lighter: 92% 0.014 90; --lch-uncolor-lightest: 96% 0.012 100; --lch-red-darkest: 26% 0.105 34; --lch-red-darker: 40% 0.154 36; --lch-red-dark: 59% 0.19 38; --lch-red-medium: 66% 0.204 40; --lch-red-light: 84.08% 0.0837 41.96; --lch-red-lighter: 92% 0.03 44; --lch-red-lightest: 96% 0.013 46; --lch-yellow-darkest: 26% 0.0729 40; --lch-yellow-darker: 40% 0.12 50; --lch-yellow-dark: 58% 0.156 60; --lch-yellow-medium: 74% 0.184 70; --lch-yellow-light: 84% 0.12 80; --lch-yellow-lighter: 92% 0.076 90; --lch-yellow-lightest: 96% 0.034 100; --lch-lime-darkest: 26% 0.064 109; --lch-lime-darker: 40% 0.101 110; --lch-lime-dark: 56.5% 0.142 111; --lch-lime-medium: 68% 0.176 113.11; --lch-lime-light: 83.92% 0.0927 113.6; --lch-lime-lighter: 92% 0.046 114; --lch-lime-lightest: 96% 0.034 115; --lch-green-darkest: 26% 0.071 149; --lch-green-darker: 40% 0.12 148; --lch-green-dark: 55% 0.162 147; --lch-green-medium: 66% 0.208 146; --lch-green-light: 83.92% 0.0772 145.06; --lch-green-lighter: 92% 0.044 144; --lch-green-lightest: 96% 0.022 143; --lch-aqua-darkest: 26% 0.059 214; --lch-aqua-darker: 40% 0.093 212; --lch-aqua-dark: 55.5% 0.122 210; --lch-aqua-medium: 66% 0.152 208; --lch-aqua-light: 83.88% 0.0555 206.02; --lch-aqua-lighter: 92% 0.02 204; --lch-aqua-lightest: 96% 0.012 202; --lch-blue-darkest: 26% 0.126 264; --lch-blue-darker: 40% 0.166 262; --lch-blue-dark: 57.02% 0.1895 260.46; --lch-blue-medium: 66% 0.196 257.82; --lch-blue-light: 84.04% 0.0719 255.29; --lch-blue-lighter: 92% 0.026 254; --lch-blue-lightest: 96% 0.016 252; --lch-violet-darkest: 26% 0.148 292; --lch-violet-darker: 40% 0.2 290; --lch-violet-dark: 58% 0.216 287.6; --lch-violet-medium: 66% 0.206 285.52; --lch-violet-light: 84.08% 0.0791 283.47; --lch-violet-lighter: 92% 0.03 282; --lch-violet-lightest: 96% 0.015 280; --lch-purple-darkest: 26% 0.131 314; --lch-purple-darker: 40% 0.178 312; --lch-purple-dark: 58% 0.21 310; --lch-purple-medium: 66% 0.258 308; --lch-purple-light: 84.09% 0.0778 305.77; --lch-purple-lighter: 92% 0.03 304; --lch-purple-lightest: 96% 0.019 302; --lch-pink-darkest: 26% 0.12 348; --lch-pink-darker: 40% 0.16 346; --lch-pink-dark: 59% 0.188 344; --lch-pink-medium: 71.8% 0.2008 342; --lch-pink-light: 84.04% 0.0737 340; --lch-pink-lighter: 92% 0.03 338; --lch-pink-lightest: 96% 0.02 336; /* Colors: Named */ --color-black: oklch(var(--lch-black)); --color-white: oklch(var(--lch-white)); --color-ink: oklch(var(--lch-ink-darkest)); --color-ink-darkest: oklch(var(--lch-ink-darkest)); --color-ink-darker: oklch(var(--lch-ink-darker)); --color-ink-dark: oklch(var(--lch-ink-dark)); --color-ink-medium: oklch(var(--lch-ink-medium)); --color-ink-light: oklch(var(--lch-ink-light)); --color-ink-lighter: oklch(var(--lch-ink-lighter)); --color-ink-lightest: oklch(var(--lch-ink-lightest)); --color-ink-inverted: oklch(var(--lch-ink-inverted)); /* Colors: Abstractions */ --color-canvas: oklch(var(--lch-canvas)); --color-negative: oklch(var(--lch-red-dark)); --color-positive: oklch(var(--lch-green-dark)); --color-link: oklch(var(--lch-blue-dark)); --color-selected-light: oklch(var(--lch-blue-lightest)); --color-selected: oklch(var(--lch-blue-lighter)); --color-selected-dark: oklch(var(--lch-blue-light)); --color-highlight: oklch(var(--lch-yellow-lighter)); --color-marker: oklch(var(--lch-red-medium)); --color-terminal-bg: oklch(98% 0.002 252); --color-terminal-text: var(--color-ink); --color-terminal-text-light: var(--color-ink-lighter); --color-golden: oklch(89.1% 0.178 95.7); --color-maybe: oklch(var(--lch-blue-medium)); /* Colors: Cards */ --color-card-default: oklch(var(--lch-blue-dark)); --color-card-complete: var(--color-ink-darker); --color-card-1: oklch(var(--lch-ink-medium)); --color-card-2: oklch(var(--lch-uncolor-medium)); --color-card-3: oklch(var(--lch-yellow-medium)); --color-card-4: oklch(var(--lch-lime-medium)); --color-card-5: oklch(var(--lch-aqua-medium)); --color-card-6: oklch(var(--lch-violet-medium)); --color-card-7: oklch(var(--lch-purple-medium)); --color-card-8: oklch(var(--lch-pink-medium)); /* Colors: Highlighter */ --highlight-1: rgb(136, 118, 38); --highlight-2: rgb(185, 94, 6); --highlight-3: rgb(207, 0, 0); --highlight-4: rgb(216, 28, 170); --highlight-5: rgb(144, 19, 254); --highlight-6: rgb(5, 98, 185); --highlight-7: rgb(17, 138, 15); --highlight-8: rgb(148, 82, 22); --highlight-9: rgb(102, 102, 102); --highlight-bg-1: rgba(229, 223, 6, 0.3); --highlight-bg-2: rgba(255, 185, 87, 0.3); --highlight-bg-3: rgba(255, 118, 118, 0.3); --highlight-bg-4: rgba(248, 137, 216, 0.3); --highlight-bg-5: rgba(190, 165, 255, 0.3); --highlight-bg-6: rgba(124, 192, 252, 0.3); --highlight-bg-7: rgba(140, 255, 129, 0.3); --highlight-bg-8: rgba(221, 170, 123, 0.3); --highlight-bg-9: rgba(200, 200, 200, 0.3); /* Colors: Syntax highlighting */ --color-code-token__att: oklch(var(--lch-blue-dark)); --color-code-token__comment: oklch(var(--lch-ink-medium)); --color-code-token__function: oklch(var(--lch-purple-dark)); --color-code-token__operator: oklch(var(--lch-red-dark)); --color-code-token__property: oklch(var(--lch-purple-dark)); --color-code-token__punctuation: oklch(var(--lch-ink-dark)); --color-code-token__selector: oklch(var(--lch-green-dark)); --color-code-token__variable: oklch(var(--lch-red-dark)); /* Colors: Generating gradient */ --color-gradient-1: oklch(var(--lch-violet-lighter)); --color-gradient-2: oklch(var(--lch-pink-lighter)); --color-gradient-3: oklch(var(--lch-purple-lighter)); --color-gradient-4: var(--color-canvas); } /* Dark mode - explicit theme choice overrides system preference */ html[data-theme="dark"] { --lch-canvas: 20% 0.0195 232.58; --lch-ink-inverted: var(--lch-black); --lch-ink-darkest: 96.02% 0.0034 260; --lch-ink-darker: 86% 0.0061 260; --lch-ink-dark: 73.97% 0.009 260; --lch-ink-medium: 62% 0.0122 260; --lch-ink-light: 40% 0.0148 260; --lch-ink-lighter: 30% 0.0178 260; --lch-ink-lightest: 25% 0.0204 260; --lch-uncolor-darkest: 96.09% 0.0076 100; --lch-uncolor-darker: 86% 0.021 90; --lch-uncolor-dark: 73.93% 0.041 80; --lch-uncolor-medium: 62% 0.0552 70; --lch-uncolor-light: 40% 0.0387 60; --lch-uncolor-lighter: 30% 0.012 50; --lch-uncolor-lightest: 25% 0.0017 40; --lch-red-darkest: 95.85% 0.0218 46; --lch-red-darker: 86% 0.086 44; --lch-red-dark: 73.95% 0.139 42; --lch-red-medium: 62% 0.154 40; --lch-red-light: 40% 0.088 38; --lch-red-lighter: 30% 0.032 36; --lch-red-lightest: 25% 0.011 34; --lch-yellow-darkest: 96% 0.056 100; --lch-yellow-darker: 86% 0.103 90; --lch-yellow-dark: 74.06% 0.136 80; --lch-yellow-medium: 62.1% 0.146 70; --lch-yellow-light: 40% 0.0736 60; --lch-yellow-lighter: 30% 0.026 50; --lch-yellow-lightest: 25% 0.01 40; --lch-lime-darkest: 96.04% 0.066 115; --lch-lime-darker: 86% 0.098 114; --lch-lime-dark: 73.97% 0.121 113; --lch-lime-medium: 62% 0.128 112; --lch-lime-light: 40% 0.0637 111; --lch-lime-lighter: 30% 0.024 110; --lch-lime-lightest: 25% 0.012 109; --lch-green-darkest: 96.12% 0.035 143; --lch-green-darker: 86% 0.082 144; --lch-green-dark: 73.99% 0.117 145; --lch-green-medium: 62% 0.1261 146; --lch-green-light: 40% 0.065 147; --lch-green-lighter: 30% 0.03 148; --lch-green-lightest: 25% 0.018 149; --lch-aqua-darkest: 96.15% 0.0244 202; --lch-aqua-darker: 86% 0.06 204; --lch-aqua-dark: 73.92% 0.095 206; --lch-aqua-medium: 62% 0.106 208; --lch-aqua-light: 40% 0.0594 210; --lch-aqua-lighter: 30% 0.028 212; --lch-aqua-lightest: 25% 0.017 214; --lch-blue-darkest: 95.93% 0.0217 252; --lch-blue-darker: 86% 0.068 254; --lch-blue-dark: 74% 0.1293 256; --lch-blue-medium: 62% 0.159 258; --lch-blue-light: 40% 0.094 260; --lch-blue-lighter: 30% 0.0452 262; --lch-blue-lightest: 25% 0.0318 264; --lch-violet-darkest: 95.97% 0.019 280; --lch-violet-darker: 86% 0.068 282; --lch-violet-dark: 74.08% 0.142 284; --lch-violet-medium: 62% 0.184 286; --lch-violet-light: 40% 0.108 288; --lch-violet-lighter: 30% 0.048 290; --lch-violet-lightest: 25% 0.025 292; --lch-purple-darkest: 95.99% 0.0217 302; --lch-purple-darker: 86% 0.068 304; --lch-purple-dark: 73.98% 0.141 306; --lch-purple-medium: 62% 0.177 308; --lch-purple-light: 40% 0.099 310; --lch-purple-lighter: 30% 0.04 312; --lch-purple-lightest: 25% 0.017 314; --lch-pink-darkest: 95.84% 0.0308 336; --lch-pink-darker: 86% 0.074 338; --lch-pink-dark: 74.04% 0.1294 340; --lch-pink-medium: 62% 0.166 342; --lch-pink-light: 40% 0.085 344; --lch-pink-lighter: 30% 0.03 346; --lch-pink-lightest: 25% 0.011 348; --color-terminal-bg: var(--color-canvas); --color-terminal-text-light: oklch(var(--lch-green-dark)); --color-golden: oklch(var(--lch-blue-medium)); --color-highlight: oklch(var(--lch-blue-lighter)); --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), 0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), 0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7), 0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8), 0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); } /* Fallback to system preference when no explicit theme is set */ @media (prefers-color-scheme: dark) { html:not([data-theme]) { --lch-canvas: 20% 0.0195 232.58; --lch-ink-inverted: var(--lch-black); --lch-ink-darkest: 96.02% 0.0034 260; --lch-ink-darker: 86% 0.0061 260; --lch-ink-dark: 73.97% 0.009 260; --lch-ink-medium: 62% 0.0122 260; --lch-ink-light: 40% 0.0148 260; --lch-ink-lighter: 30% 0.0178 260; --lch-ink-lightest: 25% 0.0204 260; --lch-uncolor-darkest: 96.09% 0.0076 100; --lch-uncolor-darker: 86% 0.021 90; --lch-uncolor-dark: 73.93% 0.041 80; --lch-uncolor-medium: 62% 0.0552 70; --lch-uncolor-light: 40% 0.0387 60; --lch-uncolor-lighter: 30% 0.012 50; --lch-uncolor-lightest: 25% 0.0017 40; --lch-red-darkest: 95.85% 0.0218 46; --lch-red-darker: 86% 0.086 44; --lch-red-dark: 73.95% 0.139 42; --lch-red-medium: 62% 0.154 40; --lch-red-light: 40% 0.088 38; --lch-red-lighter: 30% 0.032 36; --lch-red-lightest: 25% 0.011 34; --lch-yellow-darkest: 96% 0.056 100; --lch-yellow-darker: 86% 0.103 90; --lch-yellow-dark: 74.06% 0.136 80; --lch-yellow-medium: 62.1% 0.146 70; --lch-yellow-light: 40% 0.0736 60; --lch-yellow-lighter: 30% 0.026 50; --lch-yellow-lightest: 25% 0.01 40; --lch-lime-darkest: 96.04% 0.066 115; --lch-lime-darker: 86% 0.098 114; --lch-lime-dark: 73.97% 0.121 113; --lch-lime-medium: 62% 0.128 112; --lch-lime-light: 40% 0.0637 111; --lch-lime-lighter: 30% 0.024 110; --lch-lime-lightest: 25% 0.012 109; --lch-green-darkest: 96.12% 0.035 143; --lch-green-darker: 86% 0.082 144; --lch-green-dark: 73.99% 0.117 145; --lch-green-medium: 62% 0.1261 146; --lch-green-light: 40% 0.065 147; --lch-green-lighter: 30% 0.03 148; --lch-green-lightest: 25% 0.018 149; --lch-aqua-darkest: 96.15% 0.0244 202; --lch-aqua-darker: 86% 0.06 204; --lch-aqua-dark: 73.92% 0.095 206; --lch-aqua-medium: 62% 0.106 208; --lch-aqua-light: 40% 0.0594 210; --lch-aqua-lighter: 30% 0.028 212; --lch-aqua-lightest: 25% 0.017 214; --lch-blue-darkest: 95.93% 0.0217 252; --lch-blue-darker: 86% 0.068 254; --lch-blue-dark: 74% 0.1293 256; --lch-blue-medium: 62% 0.159 258; --lch-blue-light: 40% 0.094 260; --lch-blue-lighter: 30% 0.0452 262; --lch-blue-lightest: 25% 0.0318 264; --lch-violet-darkest: 95.97% 0.019 280; --lch-violet-darker: 86% 0.068 282; --lch-violet-dark: 74.08% 0.142 284; --lch-violet-medium: 62% 0.184 286; --lch-violet-light: 40% 0.108 288; --lch-violet-lighter: 30% 0.048 290; --lch-violet-lightest: 25% 0.025 292; --lch-purple-darkest: 95.99% 0.0217 302; --lch-purple-darker: 86% 0.068 304; --lch-purple-dark: 73.98% 0.141 306; --lch-purple-medium: 62% 0.177 308; --lch-purple-light: 40% 0.099 310; --lch-purple-lighter: 30% 0.04 312; --lch-purple-lightest: 25% 0.017 314; --lch-pink-darkest: 95.84% 0.0308 336; --lch-pink-darker: 86% 0.074 338; --lch-pink-dark: 74.04% 0.1294 340; --lch-pink-medium: 62% 0.166 342; --lch-pink-light: 40% 0.085 344; --lch-pink-lighter: 30% 0.03 346; --lch-pink-lightest: 25% 0.011 348; --color-terminal-bg: var(--color-canvas); --color-terminal-text-light: oklch(var(--lch-green-dark)); --color-golden: oklch(var(--lch-blue-medium)); --color-highlight: oklch(var(--lch-blue-lighter)); --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), 0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), 0 .4em 2.4em -1em oklch(var(--lch-black) / 0.7), 0 .4em .8em -1.2em oklch(var(--lch-black) / 0.8), 0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); } } ================================================ FILE: app/assets/stylesheets/access-tokens.css ================================================ .access-tokens { border-collapse: collapse; font-size: var(--text-small); inline-size: 100%; margin-block-end: 2lh; td, th { border-block-end: 1px solid var(--color-ink-lighter); text-align: start; &:first-child { inline-size: 100%; } &:not(:first-child) { padding-inline-start: var(--inline-space); } &:not(:last-child) { padding-inline-end: var(--inline-space); } } th { color: var(--color-ink-dark); font-size: var(--text-x-small); text-transform: uppercase; } td { padding-block: 8px; } } ================================================ FILE: app/assets/stylesheets/android.css ================================================ @layer platform { [data-platform~=android] { .hide-on-android { display: none; } /* Filters /* ------------------------------------------------------------------------ */ .filters { --text-x-small: 1rem; } } } ================================================ FILE: app/assets/stylesheets/animation.css ================================================ @layer utilities { .shake { animation: shake 400ms both; } @keyframes appear-then-fade { 0%,100% { opacity: 0; } 5%,60% { opacity: 1; } } @keyframes gradient { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } } /* Keyframes */ @keyframes react { 0% { transform: scale(0.85); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } } @keyframes scale-fade-out { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(0); opacity: 0; } } @keyframes shake { 0% { transform: translateX(-2rem); } 25% { transform: translateX(2rem); } 50% { transform: translateX(-1rem); } 75% { transform: translateX(1rem); } } @keyframes slide-up { from { transform: translateY(2rem); } to { transform: translateY(0); } } @keyframes slide-up-fade-in { from { transform: translateY(2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes slide-down { from { transform: translateY(0); } to { transform: translateY(2rem); } } @keyframes submitting { 0% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% } 12.5% { -webkit-mask-position: 0% 50%, 50% 0%, 100% 0% } 25% { -webkit-mask-position: 0% 100%, 50% 50%, 100% 0% } 37.5% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 50% } 50% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 100% } 62.5% { -webkit-mask-position: 0% 50%, 50% 100%, 100% 100% } 75% { -webkit-mask-position: 0% 0%, 50% 50%, 100% 100% } 87.5% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 50% } 100% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% } } @keyframes success { 0% { background-color: var(--color-border-darker); scale: 0.8; } 33% { background-color: var(--color-border-darker); scale: 1; } } @keyframes wiggle { 0% { transform: rotate(0deg); } 20% { transform: rotate(3deg); } 40% { transform: rotate(-3deg); } 60% { transform: rotate(3deg); } 80% { transform: rotate(-3deg); } 100% { transform: rotate(0deg); } } @keyframes wobble { 0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); } 15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; } 25% { border-radius: 55% 47% 62% 40% / 58% 50% 52% 44%; } 33% { border-radius: 46% 54% 61% 39% / 50% 51% 49% 50%; } 50% { border-radius: 54% 46% 61% 39% / 57% 49% 51% 43%; } 75% { border-radius: 53% 45% 60% 38% / 56% 48% 50% 42%; } } @keyframes zoom-fade { 100% { transform: translateY(-1.5em); scale: 2; opacity: 0; } } @keyframes blink { 50% { border-color: transparent; } } } ================================================ FILE: app/assets/stylesheets/attachments.css ================================================ @layer components { .attachment { block-size: auto; display: block; inline-size: fit-content; max-inline-size: 100%; position: relative; progress { inline-size: 100%; margin: auto; } } .attachment__caption { color: color-mix(in oklch, var(--color-ink) 66%, transparent); font-size: var(--text-small); textarea { --input-border-radius: 0.3em; --input-border-size: 0; --input-padding: 0; background-color: var(--input-background, transparent); border: none; color: inherit; inline-size: 100%; max-inline-size: 100%; resize: none; text-align: center; &:focus { --focus-ring-size: 0; } @supports (field-sizing: content) { field-sizing: content; inline-size: 100%; } } } .attachment__icon { aspect-ratio: 4/5; background-color: color-mix(var(--attachment-icon-color), transparent 90%); block-size: 2.5lh; border: 2px solid var(--attachment-icon-color); border-block-start-width: 1ch; border-radius: 0.5ch; box-sizing: border-box; color: var(--attachment-icon-color); display: grid; font-size: var(--text-small); font-weight: bold; inline-size: auto; padding-inline: 0.5ch; place-content: center; text-transform: uppercase; white-space: nowrap; } .attachment--preview { margin-inline: auto; text-align: center; img, video { block-size: auto; display: block; margin-inline: auto; max-inline-size: 100%; user-select: none; } > a { display: block; } .attachment__caption { column-gap: 0.5ch; display: flex; flex-wrap: wrap; justify-content: center; margin-block-start: 0.5ch; } } .attachment--file { --attachment-icon-color: var(--color-ink-medium); align-items: center; display: flex; flex-wrap: wrap; gap: 1ch; inline-size: 100%; margin-inline: 0; .attachment__caption { display: grid; flex: 1; text-align: start; } .attachment__name { color: var(--color-ink); font-weight: bold; } /* Video attachments don't have an identifiable class, but we need to * make sure the caption is always below the video */ &:has(video) { .attachment__caption { flex: none; inline-size: 100%; } } } .attachment--psd, .attachment--key, .attachment--sketch, .attachment--ai, .attachment--eps, .attachment--indd, .attachment--svg, .attachment--ppt, .attachment--pptx { --attachment-icon-color: oklch(var(--lch-red-medium)); } .attachment--css, .attachment--crash, .attachment--php, .attachment--json, .attachment--htm, .attachment--html, .attachment--rb, .attachment--erb, .attachment--ts, .attachment--js { --attachment-icon-color: oklch(var(--lch-purple-medium)); } .attachment--txt, .attachment--pages, .attachment--rtf, .attachment--md, .attachment--doc, .attachment--docx { --attachment-icon-color: oklch(var(--lch-blue-medium)); } .attachment--csv &, .attachment--numbers &, .attachment--xls &, .attachment--xlsx & { --attachment-icon-color: oklch(var(--lch-green-medium)); } .attachment__link { color: var(--color-link); text-decoration: underline; } /* Custom attachments such as mentions, etc. */ action-text-attachment[content-type^='application/vnd.actiontext'] { --attachment-bg-color: transparent; --attachment-image-size: 1em; --attachment-text-color: currentColor; align-items: center; background: var(--attachment-bg-color); border-radius: 99rem; box-shadow: -0.25ch 0 0 var(--attachment-bg-color), 0.5ch 0 0 var(--attachment-bg-color); color: var(--attachment-text-color); display: inline-flex; gap: 0.25ch; margin: 0; padding: 0; position: relative; vertical-align: bottom; white-space: normal; lexxy-editor & { cursor: pointer; } img { block-size: var(--attachment-image-size); border-radius: 50%; inline-size: var(--attachment-image-size); } &.node--selected { --attachment-bg-color: oklch(var(--lch-blue-dark)); --attachment-text-color: var(--color-ink-inverted); } } action-text-attachment[content-type^='application/vnd.actiontext.mention'] { img { object-fit: cover; } } } ================================================ FILE: app/assets/stylesheets/autoresize.css ================================================ @layer components { @supports not (field-sizing: content) { .autoresize__wrapper { display: grid !important; position: relative; > *, &::after { grid-area: 1 / 1 / 2 / 2; } &::after { content: attr(data-autoresize-clone-value) " "; font: inherit; line-height: inherit; padding-block: var(--autosize-block-padding, 0); visibility: hidden; white-space: pre-wrap; } } .autoresize__textarea { inset: 0; overflow: hidden; position: absolute; resize: none; } } } ================================================ FILE: app/assets/stylesheets/avatars.css ================================================ @layer components { .avatar { --avatar-border-radius: 50%; --avatar-size-default: 5ch; --btn-border-size: 0; aspect-ratio: 1; block-size: var(--avatar-size, var(--avatar-size-default)); border-radius: var(--avatar-border-radius); display: grid; flex-shrink: 0; inline-size: var(--avatar-size, var(--avatar-size-default)); margin: 0; place-items: center; :is(img, .icon) { aspect-ratio: 1; block-size: 100%; border-radius: var(--avatar-border-radius); grid-area: 1/1; inline-size: 100%; max-inline-size: 100%; object-fit: cover; } } .avatar__form { display: grid; grid-template-columns: 1fr auto 1fr; } } ================================================ FILE: app/assets/stylesheets/bar.css ================================================ @layer components { .bar { --row-gap: 0.2lh; background-color: var(--color-terminal-bg); block-size: calc(var(--footer-height) + env(safe-area-inset-bottom)); color: var(--color-terminal-text); display: flex; flex-direction: column; font-size: 0.9em; inset: auto 0 0 0; max-block-size: 100%; padding-block: var(--block-space) calc(var(--block-space) + env(safe-area-inset-bottom)); padding-inline: calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-left)) calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-right)); place-content: center; position: fixed; view-transition-name: bar; z-index: var(--z-bar); html[data-theme="dark"] & { border-block: 1px solid var(--color-ink-lighter); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { border-block: 1px solid var(--color-ink-lighter); } } &:has(.bar__placeholder[hidden]) { padding-inline: 1ch; } } ::view-transition-group(bar) { z-index: 99; } .bar__input { transform: translateY(50%); transition: transform 350ms cubic-bezier(0.25, 1.25, 0.5, 1); .bar:has(.bar__placeholder[hidden]) & { transform: translateY(0); } } .bar__modal { background-color: var(--color-terminal-bg); block-size: 75dvh; border-block: 1px solid var(--color-ink-lighter); inline-size: 100vw; inset: auto 0 0 0; max-inline-size: 100vw; margin-block-end: calc(var(--footer-height) - 0.3rem + env(safe-area-inset-bottom)); position: fixed; z-index: -1; &:has(#bar-content[busy]), &:has(#bar-content:not([complete])), &:has([data-search-redirect]) { display: none; } } .bar__placeholder { .btn--plain { color: inherit; font-size: var(--text-x-small); font-weight: 600; opacity: 0.66; padding-inline: 1ch; text-transform: uppercase; white-space: nowrap; &:hover { color: oklch(var(--lch-blue-dark)); opacity: 1; } } } } ================================================ FILE: app/assets/stylesheets/base.css ================================================ @layer base { html { font-size: 100%; @media (min-width: 100ch) { font-size: 1.1875rem; } } body { -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; background: var(--color-canvas); color: var(--color-ink); font-family: var(--font-sans); interpolate-size: allow-keywords; line-height: 1.375; max-inline-size: 100vw; scroll-behavior: auto; text-rendering: optimizeLegibility; text-size-adjust: none; } a { text-decoration: none; &:not([class]) { color: var(--color-link); text-decoration: underline; text-decoration-skip-ink: auto; } } :is(a, button, input, textarea, .switch, .btn) { transition: 100ms ease-out; transition-property: background-color, border-color, box-shadow, outline; touch-action: manipulation; /* Keyboard navigation */ &:where(:focus-visible) { border-radius: 0.25ch; outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } /* Default disabled styles */ &:where([disabled]) { cursor: not-allowed; opacity: 0.5; pointer-events: none; } } ::selection { background: var(--color-selected); html[data-theme="dark"] & { background-color: var(--color-selected-dark); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { background-color: var(--color-selected-dark); } } } :where(ul, ol):where([role="list"]) { margin: 0; padding: 0; list-style: none; } kbd { border: 1px solid; border-radius: 0.3em; box-shadow: 0 0.1em 0 currentColor; font-family: var(--font-mono); font-size: 0.8em; font-weight: 600; opacity: 0.7; padding: 0 0.4em; text-transform: uppercase; vertical-align: middle; white-space: nowrap; } video { max-inline-size: 100%; } /* Printing */ @page { margin: 1in; } @media print { .no-print { display: none; } } /* Turbo */ turbo-frame, turbo-cable-stream-source { display: contents; } .turbo-progress-bar { visibility: hidden; } /* Nicer scrollbars on Chrome 29+. This is intended for Windows machines, but */ /* there's not a way to target Windows using CSS, so Chrome on Mac will have */ /* slightly thinner scrollbars than normal. #C1C1C1 is the default color on Macs. */ @media screen and (-webkit-min-device-pixel-ratio:0) and (min-resolution:.001dpcm) { * { scrollbar-color: #C1C1C1 transparent; scrollbar-width: thin; } } } ================================================ FILE: app/assets/stylesheets/blank-slates.css ================================================ /* Styles for the blank slate. To manage when they are shown/hidden, do so in context */ @layer components { .blank-slate { border-radius: 0.5ch; border: 2px dashed var(--color-ink-lighter); color: var(--color-ink-dark); font-weight: 500; margin-block-start: 2dvh; padding: 1.5ch 2ch; rotate: -3deg; } .blank-slate--drag { background-color: color-mix(in srgb, transparent, var(--card-color) 5%); border-color: color-mix(in srgb, transparent, var(--card-color) 10%); color: color-mix(in srgb, transparent, var(--card-color) 75%); margin-block-start: 0; rotate: 0deg; } } ================================================ FILE: app/assets/stylesheets/bubble.css ================================================ @layer components { .bubble { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); --bubble-number-max: 26px; --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; --bubble-rotate: 0deg; --bubble-size-default: 4rem; block-size: var(--bubble-size, var(--bubble-size-default)); color: var(--card-content-color); container-type: inline-size; font-size: 1.4rem; font-weight: bold; inline-size: var(--bubble-size, var(--bubble-size-default)); inset-block-start: 20%; padding: 0.5cqi; position: absolute; z-index: 1; &:before { background: radial-gradient( color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% ); border-radius: var(--bubble-shape); content: ""; inset: 0; position: absolute; transform: rotate(var(--bubble-rotate)); z-index: -1; } @media (any-hover: hover) { &:hover:before { animation: wobble 1200ms; } } svg { display: block; letter-spacing: 0.2ch; text-transform: uppercase; } } .bubble__number { display: grid; font-size: clamp(10px, 50cqi, var(--bubble-number-max)); /* FF bug: https://app.fizzy.do/5986089/boards/2/cards/1373 */ font-weight: 900; inset: 0; place-content: center; position: absolute; text-align: center; } } ================================================ FILE: app/assets/stylesheets/buttons.css ================================================ @layer components { .btn { --icon-size: var(--btn-icon-size, 1.3em); --btn-border-radius: 99rem; --btn-hover-brightness: 0.9; align-items: center; background-color: var(--btn-background, var(--color-canvas)); border-radius: var(--btn-border-radius); border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-ink-light)); color: var(--btn-color, var(--color-ink)); cursor: pointer; display: inline-flex; font-size: 1em; font-weight: var(--btn-font-weight, 600); gap: var(--btn-gap, 0.5em); justify-content: center; padding: var(--btn-padding, 0.5em 1.1em); pointer-events: auto; position: relative; text-decoration: none; transition: 100ms ease-out; transition-property: background-color, border, box-shadow, color, opacity, scale; @media (any-hover: hover) { &:hover { filter: brightness(var(--btn-hover-brightness)); } } html[data-theme="dark"] & { --btn-hover-brightness: 1.25; } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { --btn-hover-brightness: 1.25; } } &[disabled], &:has([disabled]), [disabled] &[type=submit], &[type=submit]:disabled { cursor: not-allowed; opacity: 0.3; pointer-events: none; } form[aria-busy] &:disabled { position: relative; > * { visibility: hidden; } &::after { --mask: no-repeat radial-gradient(#000 68%,#0000 71%); --size: 1.25em; -webkit-mask: var(--mask), var(--mask), var(--mask); -webkit-mask-size: 28% 45%; animation: submitting 1s infinite linear; aspect-ratio: 8/5; background: currentColor; content: ""; inline-size: var(--size); inset: 50%; margin-block: calc((var(--size) / 3) * -1); margin-inline: calc((var(--size) / 2) * -1); position: absolute; } } } /* Variants /* ------------------------------------------------------------------------ */ .btn--plain { --btn-background: transparent; --btn-border-radius: 0; --btn-border-size: 0; --btn-color: inherit; --btn-icon-size: 100%; --btn-padding: 0; } .btn--link { --btn-background: var(--color-link); --btn-border-color: var(--color-canvas); --btn-color: var(--color-ink-inverted); --focus-ring-color: var(--color-link); } .btn--circle, .btn[aria-label]:where(:has(.icon)), .btn:where(:has(.for-screen-reader):has(.icon)) { --btn-padding: 0; --icon-size: 75%; aspect-ratio: 1; block-size: var(--btn-size); display: grid; inline-size: var(--btn-size); justify-content: normal; /* FF fix */ place-items: center; > * { grid-area: 1/1; } } /* Make a normal button circular on mobile */ @media (max-width: 639px) { .btn--circle-mobile { --btn-size: 3em; --btn-padding: 0; --icon-size: 75%; aspect-ratio: 1; inline-size: var(--btn-size); kbd, span:last-of-type:not(.icon) { display: none; } } } @media (min-width: 640px) { .btn .icon--mobile-only { display: none !important; } } .btn--negative { --btn-background: var(--color-negative); --btn-border-color: var(--color-negative); --btn-color: var(--color-ink-inverted); --focus-ring-color: var(--color-negative); } .btn--positive { --btn-background: var(--color-positive); --btn-border-color: var(--color-canvas); --btn-color: var(--color-ink-inverted); --focus-ring-color: var(--color-positive); } .btn--success { --success-timing-function: cubic-bezier(0.25, 1.25, 0.5, 1); animation: success 1s var(--success-timing-function); .icon { animation: zoom-fade 500ms var(--success-timing-function); } } /* Fake button used to help space things out */ .btn--placeholder { pointer-events: none; visibility: hidden; } .btn--remove { --btn-icon-size: 0.7em; } .btn--reversed { --btn-background: var(--color-ink); --btn-border-color: var(--color-canvas); --btn-color: var(--color-canvas); --focus-ring-color: var(--color-ink); } /* Toggleable buttons /* ------------------------------------------------------------------------ */ .btn { &:has(input[type=radio], input[type=checkbox]) { position: relative; :is(input[type=radio], input[type=checkbox]) { appearance: none; border-radius: var(--btn-border-radius); cursor: pointer; display: flex; inset: 0; margin: 0; padding: 0; position: absolute; &:focus-visible { outline: none; } } .checked { display: none; } } &:has(input:checked) { --btn-background: var(--color-ink); --btn-border-color: var(--color-ink); --btn-color: var(--color-ink-inverted); --focus-ring-color: var(--color-ink); .checked { display: block; } } &:has(input:focus-visible) { outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } .btn--back { --btn-border-size: 0; @media (max-width: 639px) { strong, kbd { display: none; } } @media (min-width: 640px) { font-size: var(--text-medium); .icon--arrow-left { display: none; } } } /* Button groups /* ------------------------------------------------------------------------ */ .btn__group { .btn { --btn-border-radius: 0; --radius: 0.3em; flex: 1 0 33%; inline-size: 100%; justify-content: center; white-space: nowrap; } form { flex: 1 1 0%; } :first-of-type .btn { border-end-start-radius: var(--radius); border-inline-end: 0; border-start-start-radius: var(--radius); padding-inline-end: 0.8em; } :last-of-type .btn { border-end-end-radius: var(--radius); border-inline-start: 0; border-start-end-radius: var(--radius); padding-inline-start: 0.8em; } span { inline-size: 100%; } } /* Button utilities /* ------------------------------------------------------------------------ */ :is([data-platform~=mobile], [data-platform~=native]) { .btn--ensure-tap-target-size { --tap-target-z-index: 1; --tap-target-min-size: 44px; z-index: var(--tap-target-z-index); &::before { content: ""; display: block; block-size: var(--tap-target-min-size); inline-size: var(--tap-target-min-size); inset: calc(50% - var(--tap-target-min-size) / 2) auto auto calc(50% - var(--tap-target-min-size) / 2); opacity: 0; position: absolute; } } } } ================================================ FILE: app/assets/stylesheets/card-columns.css ================================================ @layer components { /* Layout adjustments for contained scrolling /* ------------------------------------------------------------------------ */ /* Scroll columns individually on mobile */ @media (max-width: 639px) { body.contained-scrolling { block-size: 100dvh; grid-template-rows: 1fr var(--footer-height); #global-container { display: grid; grid-template-rows: auto 1fr; overflow: hidden; } #main { display: grid; grid-template-rows: auto auto 1fr; overflow: auto; padding: 0; } /* Adapt the grid to public views (no filters or watchers sections) */ &.public #main { grid-template-rows: 1fr; } } } /* Column container /* ------------------------------------------------------------------------ */ #main:has(.card-columns) { --main-padding: 0; } .card-columns { --bubble-size: 3.5rem; --cards-gap: min(1.2cqi, 1.7rem); --column-gap: 8px; --column-padding: calc(var(--column-gap) * 2); --column-transition-duration: 300ms; --column-width-collapsed: 40px; --column-width-expanded: 450px; --progress-increment: var(--progress-max-height) / var(--progress-max-cards); --progress-max-cards: 15; /* should match first geared pagination page size */ --progress-max-height: 50dvh; container-type: inline-size; display: grid; gap: var(--column-gap); grid-template-columns: 1fr auto 1fr; inline-size: 100%; margin-inline: auto; max-inline-size: var(--main-width); outline: none; overflow-x: auto; overflow-y: hidden; position: relative; /* When it has something expanded */ &:has(.card-columns__left .is-expanded, .card-columns__right .is-expanded) { grid-template-columns: auto auto auto; @media (min-width: 640px) { grid-template-columns: auto var(--column-width-expanded) auto; } } &:has(.cards) { block-size: 100%; min-block-size: 20lh; } @media (max-width: 639px) { --column-width-expanded: calc(100vw - var(--column-gap) * 4); scroll-snap-type: inline mandatory; &:not(:has(.is-expanded)) { grid-template-columns: auto var(--column-width-collapsed) auto; } } @media (min-width: 640px) { padding-block-end: var(--column-width-collapsed); } } .card-columns__left, .card-columns__right { align-items: stretch; display: flex; gap: var(--column-gap); position: relative; @media (max-width: 639px) { min-block-size: 0; } } .card-columns__left { justify-content: end; margin-inline-start: auto; padding-inline-start: var(--column-gap); @media (max-width: 639px) { padding-inline-start: calc(var(--column-gap) * 2); } } .card-columns__right { justify-content: start; padding-inline-end: var(--column-gap); margin-inline-end: auto; } /* Column /* ------------------------------------------------------------------------ */ .cards { --column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas)); inline-size: var(--column-width-expanded); outline: none; position: relative; scroll-snap-align: center; &.is-expanded { @media (max-width: 639px) { overflow: hidden; } } &.is-collapsed { inline-size: var(--column-width-collapsed); .pagination-link.pagination-link--active-when-observed, .card { display: none; } } &.drag-and-drop__hover-container { --dnd-bg-color: transparent; --dnd-border-color: transparent; &.is-off-screen { &:after { content: attr(data-column-name); font-size: var(--text-x-small); font-weight: 500; line-height: var(--column-width-collapsed); padding-inline: 1ch; position: fixed; text-transform: uppercase; top: 0; translate: -50%; } &.is-collapsed { &:after { writing-mode: vertical-rl; } } &:not(.is-collapsed) { &:after { background-color: var(--column-color); inline-size: calc(var(--column-width-expanded) - 4px); /* make room for the dnd border */ } } } } @media (any-hover: hover) { .card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) { filter: blur(3px) brightness(1.2); opacity: 0.2; } } } .cards__transition-container { block-size: 100%; border-radius: calc(var(--column-width-collapsed) / 2); margin-block-start: 0.5ch; /* Allow a little room for the mini bubble */ transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle); @media (min-width: 640px) { .is-expanded & { translate: 0; /* Animate back from collapsed state */ } .is-collapsed & { margin-block-start: 0; translate: 0 var(--column-width-collapsed); } } .drag-and-drop__hover-container & { --dnd-bg-color: var(--column-color); --dnd-border-color: var(--card-color); background-color: var(--dnd-bg-color); outline: 2px dashed var(--dnd-border-color); outline-offset: -2px; transition: background-color 200ms; z-index: 1; } .no-transitions & { transition: none; } /* Use flex so the __list container can take up the remaining space for scrolling */ @media (max-width: 639px) { .is-expanded & { display: flex; flex-direction: column; } } } /* The wrapper around the cards used to clip overflow while transitioning. * Also, don't resize cards while transitioning to avoid reflow. */ .cards__list { display: flex; flex-direction: column; gap: var(--cards-gap); overflow-x: hidden; overflow-y: auto; .is-expanded & { padding: var(--column-padding) var(--column-padding) calc(var(--column-padding) + var(--custom-safe-inset-bottom)); /* Use the rest of the column height for scrolling */ @media (max-width: 639px) { flex: 1; padding-inline: calc(var(--column-padding) / 4); } } [aria-selected] & .card[aria-selected] { outline: var(--focus-ring-size) solid var(--color-selected-dark); outline-offset: var(--focus-ring-offset); html[data-theme="dark"] & { outline-color: oklch(var(--lch-blue-medium)); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { outline-color: oklch(var(--lch-blue-medium)); } } } &:has(.card) { .blank-slate { display: none; } } /* Use the default blank-slate on small viewports since drag-and-drop isn't available */ [data-controller~="drag-and-drop"] & { @media (max-width: 639px) { .blank-slate--drag { display: none; } } @media (min-width: 640px) { .blank-slate--default { display: none; } } } } .cards__new-column { position: relative; @media (max-width: 639px) { inset-inline-end: 0; position: absolute; translate: 100%; z-index: 2; } @media (min-width: 640px) { margin-block-start: var(--column-width-collapsed); } } /* Cards grid; used when filtering /* -------------------------------------------------------------------------- */ .cards--grid { --cards-gap: 1rem; --card-grid-columns: 1; container-type: inline-size; inline-size: 100%; margin-inline: auto; max-inline-size: var(--main-width); @media (min-width: 640px) { --card-grid-columns: 2; } @media (min-width: 960px) { --card-grid-columns: 3; } .cards__list { display: flex; flex-direction: row; flex-wrap: wrap; gap: var(--cards-gap); justify-content: center; padding: 1ch; } .card { inline-size: calc((100% - var(--cards-gap) * (var(--card-grid-columns) - 1) ) / var(--card-grid-columns)); } .card__header .card__column-name--current { --btn-padding: 0.1em 0.5em; background: none; border: 1px solid currentColor; color: var(--card-color); display: inline-flex; flex: 0 1 auto; inline-size: fit-content; margin: 0 0 0 auto; } .blank-slate--drag { display: none; } } /* Column Elements /* ------------------------------------------------------------------------ */ .cards__header { .cards.is-collapsed & { block-size: 100%; } .cards.is-expanded & { display: grid; grid-template-areas: "menu expander maximize"; grid-template-columns: var(--column-width-collapsed) 1fr var(--column-width-collapsed); padding-inline: var(--column-padding); } } .cards__menu .btn--circle, .cards__maximize-button { --btn-background: transparent; block-size: var(--column-width-collapsed); inline-size: var(--column-width-collapsed); opacity: 0; outline-offset: -2px; .cards:hover &, &:focus-visible { opacity: 1; } .cards.is-collapsed & { display: none; } } .cards__menu { position: relative; z-index: var(--z-popup); } .cards__maximize-button { grid-area: maximize; } .cards__expander { --gradient-direction: to bottom; align-items: center; border-radius: 99rem; cursor: pointer; display: flex; flex-direction: row-reverse; font-size: var(--text-x-small); font-weight: 600; gap: 0.5ch; grid-area: expander; justify-content: center; outline: none; outline-offset: -2px; position: relative; text-transform: uppercase; &[disabled] { opacity: 1; } @media (any-hover: hover) { .is-collapsed:hover { filter: brightness(0.9); } } /* Progress */ &:after { background: linear-gradient(var(--gradient-direction), var(--card-color), var(--column-color) 80%); block-size: var(--column-width-collapsed); border-radius: 99rem; content: ""; inset: 0 0 auto; margin-inline: auto; max-block-size: var(--progress-max-height); min-block-size: var(--column-width-collapsed); opacity: 0; position: absolute; transition: block-size 500ms var(--ease-out-overshoot), inline-size var(--column-transition-duration) ease-out, opacity var(--column-transition-duration) ease-out; z-index: -1; } .no-transitions &:after { transition: none; } .cards.is-collapsed & { block-size: 100%; flex-direction: column; inline-size: var(--column-width-collapsed); justify-content: start; letter-spacing: 0.05em; /* Guitar string */ &:before { background-color: var(--column-color); block-size: 100%; content: ""; inline-size: 1px; inset-block: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)) 0; position: absolute; z-index: -2; } &:after { block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)); max-block-size: none; opacity: 1; inline-size: var(--column-width-collapsed); } } .cards.is-expanded & { inline-size: 100%; justify-content: center; } } .cards__expander-count { line-height: var(--column-width-collapsed); inline-size: var(--column-width-collapsed); .cards.is-expanded & { display: none; } } .cards__expander-title { font-weight: inherit; font-size: inherit; line-height: var(--column-width-collapsed); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; .cards.is-collapsed & { max-inline-size: 50vh; writing-mode: vertical-rl; } .cards.is-expanded & { align-items: center; display: flex; gap: 0.25ch; max-inline-size: calc(100% - var(--column-width-collapsed) * 2); } .icon--collapse { --icon-size: 1.15em; opacity: 0.66; transition: 150ms ease-out; transition-property: opacity, scale; @media (min-width: 640px) { opacity: 0; scale: 1.5; } .cards.is-collapsed & { display: none; } .cards.is-expanded .cards__expander:hover & { opacity: 0.66; scale: 1; } } } /* Override card styles within columns /* Adding .board-tools here since it sits outside the cards container on mobile */ /* ------------------------------------------------------------------------ */ .cards .card, .board-tools { --block-space: 1em; --block-space-half: 0.5em; --card-padding-inline: 1em; --text-xx-large: 1.6em; --text-x-small: 1em; /* Set lower limit for font size */ font-size: clamp(0.6rem, 0.85cqi, 100px); .card__counts { --gap: 0.5ch; align-items: flex-end; display: flex; flex-shrink: 0; gap: calc(2 * var(--gap)); margin-inline: auto calc(var(--card-padding-inline) * -0.5); padding-inline-start: var(--gap); } .card__boosts, .card__comments { --icon-size: 1.6em; align-items: center; display: flex; flex-shrink: 0; font-weight: 600; gap: var(--gap); img { block-size: var(--icon-size); inline-size: var(--icon-size); } .icon--comment { color: var(--card-color); } } .card__steps { --column-gap: 0.8ch; display: flex; } .card__tags { gap: calc(var(--card-header-space) / 2); } .card__title { pointer-events: none; } .card__link { z-index: 1; } .card__stages, .card__hide-on-index { display: none; } .card__body { padding-block: calc(var(--card-padding-block) * 0.75) var(--card-padding-block); } .card__meta { font-weight: 600; strong, .local-time-value { font-weight: inherit; } @media (max-width: 639px) { inline-size: auto; } } &:has(.card__background img:not([src=""])) { .card__content, .card__meta, .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 0; transition: opacity 0.2s ease-in-out; } @media (any-hover: hover) { &:hover { .card__content, .card__footer, .card__boosts, .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 1; } .card__background img { filter: blur(3px) brightness(1.2); opacity: 0.2; } } } } .bubble { inset-inline-start: 100%; translate: -90% -40%; } } /* Considering /* ------------------------------------------------------------------------ */ .cards--maybe { --card-color: oklch(var(--lch-blue-medium)); position: relative; .card { --avatar-size: 2.75em; --text-small: 1.1em; background-color: var(--color-canvas); line-height: 1.2; z-index: 2; @media (min-width: 640px) { --text-xx-large: 1.6em; } } .card__board { background-color: transparent; color: var(--card-content-color); } .card__header { color: var(--color-ink); padding-block-start: calc(var(--card-padding-block) / 2); } .card__tags { color: inherit; } .card__body { padding-block: 0 var(--card-padding-block); } .card__people-label { display: none; } .card__title { min-block-size: 0; } } /* Board tools /* -------------------------------------------------------------------------- */ .board-tools.card { --border-color: var(--color-selected-dark); --border-size: 1px; --card-padding-block: var(--block-space); border: 1px solid var(--border-color); inline-size: auto; text-align: center; @media (max-width: 639px) { /* On mobile, hide the tool card inside the Maybe column */ .cards & { display: none; } #cards_container > & { margin: 0 3ch 1ch; } } @media (min-width: 640px) { /* On desktop, hide the tool card above the columns */ #cards_container > & { display: none; } } @media (min-width: 800px) { margin: var(--column-padding) var(--column-padding) 0; } &:has(dialog[open]) { z-index: 5; } .divider { --divider-color: oklch(var(--lch-blue-light)); } .btn--link { font-size: 1.2em; } .btn:not(.btn--link, .btn--circle) { border: 0; color: var(--color-link); } footer { font-size: var(--text-x-small); margin-block: 1ch calc(var(--block-space-half) * -1); } .overflow-count { font-size: 1.2em; font-weight: 500; padding: 0.5em 0.3em; } } .board-tools__watching { --btn-size: 32px; --gap: 0.5ch; display: flex; gap: var(--gap); inline-size: 100%; margin-block: var(--block-space-half); place-content: center; position: relative; } .board-tools__watching-dialog { --panel-padding: 2ch; --panel-size: 100%; flex-wrap: wrap; gap: var(--gap); inset-block-start: 0; justify-content: center; position: absolute; z-index: 1; &[open] { display: flex; } } /* On Deck (Not Now) /* ------------------------------------------------------------------------ */ .cards--closed, .cards--on-deck { --card-color: var(--color-ink-light) !important; .card, .blank-slate { --card-color: var(--color-card-complete) !important; } .bubble { display: none !important; } } /* Doing /* -------------------------------------------------------------------------- */ /* Surface a mini bubble if there are cards with bubbles inside */ .cards--maybe:has(.bubble:not([hidden])) .cards__expander-title, .cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container, .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); --bubble-opacity: 75%; --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; &:before { background: radial-gradient( color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%, color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100% ); block-size: 1em; border-radius: var(--bubble-shape); content: ""; inline-size: 1em; inset: 0 0 auto auto; position: absolute; translate: 20% -20%; z-index: 1; @media (max-width: 639px) { translate: 20% 0%; } } /* Maybe column: position bubble relative to the title, not the container */ .cards--maybe.is-expanded & { overflow: visible; position: relative; &:before { inset-block-start: 50%; inset-inline-start: 0; translate: -125% -75%; z-index: -1; } } @media (max-width: 639px) { &.cards__expander-title:before { display: none; } } html[data-theme="dark"] & { --bubble-opacity: 100%; } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { --bubble-opacity: 100%; } } } /* Card column indicators /* -------------------------------------------------------------------------- */ .card__column-name { --btn-background: transparent; --btn-padding: 0.2em 0.5em; --btn-border-size: 0; --btn-border-radius: 0.2em; color: inherit; inline-size: 100%; justify-content: flex-start; text-transform: uppercase; @media (hover: hover) { &:not(.card__column-name--current):hover { --btn-background: color(from var(--column-color) srgb r g b / 0.15); color: var(--column-color); } } } .card__column-name--current { --btn-background: var(--card-color); color: var(--color-ink-inverted); opacity: 1 !important; @media (hover: hover) { &:hover { --btn-background: var(--card-color); } } } } ================================================ FILE: app/assets/stylesheets/card-perma.css ================================================ /* Card container for the perma. Tools and actions and whatnot */ @layer components { .card-perma { --actions-block-inset: 1.5rem; --actions-inline-inset: 4rem; --color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-canvas)); --half-btn-height: 1.25rem; --padding-inline: calc(var(--block-space-double) + var(--block-space)); --padding-block: calc(var(--block-space-double) + var(--block-space-half)); align-items: start; column-gap: var(--inline-space); display: grid; grid-template-areas: "notch-top notch-top notch-top" "actions-left card actions-right" "notch-bottom notch-bottom notch-bottom" "closure-message closure-message closure-message"; grid-template-columns: 48px minmax(0, 1120px) 48px; inline-size: fit-content; margin-block-start: var(--block-space); max-inline-size: 100%; margin-inline: auto; position: relative; &:has(dialog[open]) { z-index: 3; } &:has(.card-perma__star-input:checked) { .card { outline: 4px solid var(--color-negative); } } @media (max-width: 799px) { --half-btn-height: 1.25rem; --padding-inline: 1.5ch; column-gap: 0; grid-template-areas: "notch-top notch-top notch-top" "card card card" "actions-left notch-bottom actions-right" "closure-message closure-message closure-message"; grid-template-columns: 1fr auto 1fr; inline-size: calc(100% + 2 * var(--padding-inline)); margin-inline: calc(-1 * var(--padding-inline)); max-inline-size: none; position: relative; } .card { --card-aspect-ratio: 2 / 0.95; --lexxy-bg-color: var(--card-bg-color); border: none; } .card__background { filter: brightness(1.2) contrast(0.8); opacity: 0.2; } .card__header { @media (max-width: 639px) { flex-wrap: wrap; gap: var(--card-header-space) unset; } } .card__tags { @media (max-width: 639px) { padding: 0.25lh; } } .card__tags-list { @media (min-width: 640px) { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } } .card__body { position: relative; @media (max-width: 639px) { flex-direction: column; padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5); position: static; } } .card__content { display: flex; flex-direction: column; gap: 1ch; } .card__title { font-size: clamp(var(--text-medium), 6vw, var(--text-x-large)); margin-block-end: 0.5ch; /* With tight line spacing, Windows will cover over adjacent lines of text * when input text is selected. Here, we're setting the selection color to a * transparent value so the overlapping text lines are at least visible. */ ::selection { background: oklch(var(--lch-blue-light) / 0.5); } &:has(textarea) { @media (min-width: 640px) { margin-block-end: 0; } @supports not (field-sizing: content) { text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */ } } @media (max-width: 639px) { margin-block-end: 0.75ch; } } .card__description { @media (max-width: 639px) { margin-block-end: 1ch; } } .card__meta, .card__stages { @media (min-width: 640px) { font-size: var(--text-small); } } .card__meta { grid-area: meta; margin-inline-end: auto; @media (max-width: 639px) { --meta-spacer-block: 0.75ch; min-inline-size: 0; gap: calc(var(--meta-spacer-block) / 2); display: flex; flex-wrap: wrap; .card__meta-text { border: 0; padding: 0; } .card__meta-avatars--author { --btn-size: 1.5em; display: initial; margin-inline-end: unset; order: 3; } .card__meta-text--added { inline-size: 100%; order: 1; } .card__meta-text--author { order: 2; } .card__meta-text--updated { border-block-start: var(--card-border); inline-size: 100%; margin-block-start: calc(var(--meta-spacer-block) * 0.5); order: 4; padding-block-start: var(--meta-spacer-block); } .card__meta-text--assignees { margin-block-start: calc(var(--meta-spacer-block) * 3); order: 6; white-space: unset !important; } .card__meta-avatars--assignees { margin-inline: 0 var(--meta-spacer-inline); margin-block-start: calc(var(--meta-spacer-block) * 3); order: 5; .avatar { display: grid; } } &:has(.card__meta-avatars--assignees .avatar) { .card__meta-text--assignees { order: 5; } .card__meta-avatars--assignees { margin-block-start: var(--meta-spacer-block); order: 6; } } } } &:has(.card__closed) .card__meta { @media (max-width: 639px) { .card__meta-avatars--assignees { display: none; } } } .card__stages { max-inline-size: 32ch; @media (max-width: 639px) { border: 1px solid var(--card-color); border-radius: calc(0.2em + 3px); flex-direction: row; gap: 0; overflow: auto; max-inline-size: 100%; padding: 3px; position: relative; white-space: nowrap; & > form { flex-grow: 1; max-inline-size: 25ch; min-block-size: 2.5em; } & > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) { box-shadow: -1px 0 0 0 var(--color-container); } } } .card__column-name { @media (max-width: 639px) { justify-content: center; } } .card__closed { @media (max-width: 639px) { inset: auto 0 3rem auto; scale: 75%; } } .card__footer { --btn-size: 2.5rem; display: flex; gap: 0.5ch; inline-size: 100%; text-align: start; /* Switch to grid layout so that the bg zoom button can stay next to the * meta element, and the reactions can sit below */ &:has(.reaction) { display: grid; grid-template-columns: 1fr auto; grid-template-areas: "meta bg-zoom" "reactions reactions"; } @media (max-width: 639px) { display: grid; font-size: var(--text-x-small); gap: 1ch 0; grid-template-columns: 1fr auto; grid-template-areas: "meta bg-zoom" "meta reactions"; &:not(:has(.reaction)), &:has(.card__background) { column-gap: 2ch; } } } .reactions { --reaction-size: var(--btn-size); align-self: flex-end; display: flex; gap: 0.5ch; grid-area: reactions; margin-inline-start: auto; &:has(.reaction) { --padding: calc(var(--card-padding-block) / 2); --reaction-size: 1.6875rem; margin-block: var(--padding) calc(-1 * var(--padding)); padding-block-start: var(--padding); position: relative; &:before { border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%); content: ""; inset: 0 calc(-1 * var(--card-padding-inline)) auto; position: absolute; } @media (any-hover: none) { --reaction-size: 2.25rem; } } &:not(:has(.reaction)) { margin: 0; .reactions__trigger { --btn-border-color: var(--color-ink-light); } } } .reaction__popup.popup { inline-size: max-content; } .card__zoom-bg-btn { grid-area: bg-zoom; } .bubble { --bubble-number-max: 42px; --bubble-size: 6rem; inset: calc(var(--bubble-size) / -4) calc(var(--bubble-size) / 1.5) auto auto; translate: 0 0; @media (max-width: 799px) { --bubble-size: 4.5rem; inset: calc(var(--bubble-size) / 1.5) 0 auto auto; } } } /* Child items /* ------------------------------------------------------------------------ */ .card-perma__bg { background-color: var(--color-container); border-radius: 0.2em; grid-area: card; padding: clamp(2rem, 4vw, var(--padding-block)); @media (max-width: 639px) { padding: clamp(0.25rem, 2vw, var(--padding-block)); padding-block-end: clamp(2.5rem, 4vw, var(--padding-block)); } @media (min-width: 640px) and (max-width: 799px) { padding-inline: var(--padding-inline); } } .card-perma__actions { display: grid; gap: var(--block-space-half); &:has([open]) { position: relative; z-index: 1; } &:has([data-controller~="tooltip"]:hover) { z-index: var(--z-tooltip); } } .card-perma__actions--left { grid-area: actions-left; } .card-perma__actions--right { grid-area: actions-right; } @media (max-width: 799px) { .card-perma__actions { display: flex; padding-inline: var(--padding-inline); translate: 0 -50%; } .card-perma__actions--right { inset-inline-end: 0; justify-content: flex-end; } } .card-perma__image-btn { &:has(input[type="file"]:focus), &:has(input[type="file"]:focus-visible) { outline: var(--focus-ring) !important; outline-offset: var(--focus-ring-offset); } input[type="file"] { outline: none; } } .card__banner { align-items: center; background-color: var(--color-highlight); border-radius: 2em; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); display: inline-flex; inline-size: auto; gap: var(--inline-space-half); grid-area: notch-top; justify-content: center; margin-block-start: -4ch; margin-inline: auto; max-inline-size: 36ch; padding: var(--block-space-half) var(--block-space); position: relative; text-align: center; translate: 0 50%; z-index: 0; .btn { --btn-background: var(--card-color); --btn-border-color: var(--card-color); --btn-color: var(--color-ink-inverted); } } /* Notches /* -------------------------------------------------------------------------- */ .card-perma__notch { align-items: center; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); display: inline-flex; inline-size: auto; gap: var(--inline-space); justify-content: center; margin-inline: auto; position: relative; text-align: center; z-index: 0; } .card-perma__notch--top { grid-area: notch-top; inline-size: 100%; margin-block-start: -4ch; max-inline-size: 36ch; padding-inline: 1ch; translate: 0 50%; .btn { --btn-border-color: var(--card-color); --btn-color: var(--card-color); text-align: center; &:has(input:checked) { --btn-background: var(--card-color); --btn-border-color: var(--card-color); --btn-color: var(--color-ink-inverted); } } } .card-perma__notch--bottom { grid-area: notch-bottom; /* Overlap the card BG by half the button height */ &:has(.btn) { translate: 0 calc(-1 * var(--half-btn-height)); } form { background-color: var(--color-canvas); border-radius: 99rem; } .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) { --btn-background: var(--card-color); --btn-color: var(--color-ink-inverted); } .btn--reversed { --btn-background: var(--color-canvas); --btn-color: var(--card-color); --btn-border-color: var(--color-container); } @media (max-width: 639px) { flex-direction: column; } } .card-perma__notch-new-card-buttons { display: flex; gap: var(--inline-space-half); @media (max-width: 479px) { flex-direction: column; .btn { inline-size: 100%; } } } .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; margin-block: var(--block-space) var(--block-space-double); padding-inline: 1ch; .btn--plain { --btn-color: var(--card-color); text-decoration: underline; } @media (max-width: 799px) { margin-block: var(--block-space-half); translate: 0 calc(-0.5 * var(--half-btn-height)); } @media (min-width: 800px) { .card-perma__notch--bottom:has(.btn) ~ & { margin-block: var(--block-space-half) var(--block-space); translate: 0 calc(-0.5 * var(--half-btn-height)); } } } .card-perma__account-limit-message { background-color: var(--color-canvas); border: 2px solid var(--color-container); border-radius: 4px; margin-block-start: calc(var(--padding-block) / -2); padding: 1ch 2ch; } } ================================================ FILE: app/assets/stylesheets/cards.css ================================================ @layer components { /* Base /* ------------------------------------------------------------------------ */ .card { --avatar-size: 2.75em; --card-bg-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas)); --card-content-color: color-mix(in srgb, var(--card-color) 30%, var(--color-ink)); --card-text-color: color-mix(in srgb, var(--card-color) 75%, var(--color-ink)); --card-border: 1px solid color-mix(in srgb, var(--card-color) 33%, var(--color-ink-inverted)); --card-header-space: 1ch; --card-padding-inline: var(--inline-space-double); --card-padding-block: var(--block-space); --border-color: transparent; --border-radius: 0.2em; --border-size: 0; aspect-ratio: var(--card-aspect-ratio, auto); background-color: var(--card-bg-color); border-radius: var(--border-radius); box-shadow: var(--shadow); display: flex; flex-direction: column; inline-size: 100%; padding: var(--card-padding-block) var(--card-padding-inline); position: relative; text-align: start; z-index: 1; html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } } .popup { inline-size: 260px; } } /* Header /* ------------------------------------------------------------------------ */ .card__header { align-items: center; border-radius: var(--border-radius) 0 0 0; display: flex; flex-wrap: nowrap; gap: var(--card-header-space); margin-block-start: calc(-1 * var(--card-padding-block)); margin-inline: calc(-1 * var(--card-padding-inline)) calc(-0.5 * var(--card-padding-inline)); max-inline-size: unset; min-inline-size: 0; .card__column-name { display: none; } } .card__board { align-items: center; align-self: start; background-color: var(--card-color); border-radius: var(--border-radius) 0 var(--border-radius) 0; color: var(--color-ink-inverted); display: inline-flex; font-weight: 600; max-inline-size: 100%; min-inline-size: 0; padding-block: 0.25lh; padding-inline: var(--card-padding-inline) 1ch; position: relative; transition: background-color 100ms ease-out; &:has(.btn) { @media (any-hover: hover) { &:hover { background-color: color-mix(in srgb, var(--card-color) 90%, var(--color-ink)); } } } dialog { inset-block-start: 100%; } } .card__id { flex-shrink: 0; .card-perma &:before { content: "No."; opacity: 0.5; } } .card__board-name { align-items: center; border-inline-start: 1px solid color-mix(in hsl, transparent 75%, currentColor); color: currentColor; display: flex; gap: 0.25ch; margin-inline-start: var(--card-header-space); max-inline-size: 100%; min-inline-size: 0; padding-inline-start: var(--card-header-space); text-transform: uppercase; } .card__board-picker-button { inset: 0; position: absolute; } .card__tags { --btn-color: var(--card-color); align-items: center; align-self: stretch; color: var(--card-text-color); display: flex; gap: 0.5ch; min-inline-size: 0; [data-controller="dialog"] { align-items: center; align-self: stretch; display: flex; position: relative; } .popup { --panel-size: 18ch; inset-block-start: 100%; } } .card__tag-picker { --panel-border-radius: 2em; --panel-padding: 0.5em 0.7em; --panel-size: max-content; inline-size: auto !important; inset: 0 auto auto 0; max-inline-size: var(--panel-size) !important; position: absolute; z-index: 2; &[open] { display: flex; } .input { --input-padding: 0.2em 0.5em; inline-size: 18ch; } } .card__tag-picker-button { font-size: 0.6em; } .card__tag { color: inherit; font-weight: 600; min-width: 0; text-transform: uppercase; } /* Body /* ------------------------------------------------------------------------ */ .card__body { display: flex; flex-grow: 1; gap: 1ch; inline-size: 100%; padding-block: calc(var(--card-padding-block) / 2); @media (min-width: 640px) { gap: var(--card-padding-inline); } } .card__content { color: var(--card-content-color); contain: inline-size; flex: 2 1 auto; max-inline-size: 100%; } .card__title { --autosize-block-padding: 0 0.5ch; --input-border-radius: 0; --input-color: var(--card-content-color); --lines: 3; color: var(--card-content-color); font-size: var(--text-xx-large); font-weight: 900; line-height: 1.15; text-wrap: balance; &.overflow-line-clamp { text-wrap: unset; /* text-wrap: balance breaks -webkit-line-clamp in Safari */ } .card-field__title { overflow: hidden; /* prevent scrolling on windows */ padding-block: var(--autosize-block-padding); &:is(textarea)::placeholder { color: inherit; opacity: 0.66; } } .card__title-link { color: inherit; } code { background-color: var(--color-canvas); border: 1px solid var(--color-ink-lighter); border-radius: 0.25ch; font-family: var(--font-mono); font-size: smaller; padding: 0.1ch 0.25ch; } } .card__description { /* Hide the empty element that Lexical saves when nothing is added to the description


*/ action-text-content p:only-child:has(br:only-child) { display: none; } lexxy-toolbar { border-block-start: 1px solid var(--lexxy-border-color); } & ~ .btn.btn--reversed { --btn-background: var(--card-color); --btn-color: var(--color-ink-inverted); } & ~ .btn { --btn-border-color: var(--card-color); --btn-color: var(--card-color); } } .card__stages { color: var(--card-text-color); display: flex; flex: 0 1 auto; flex-direction: column; gap: 2px; justify-self: end; max-inline-size: 20ch; padding-block: var(--block-space-half); } /* Footer /* ------------------------------------------------------------------------ */ /* Card metadata */ .card__meta { --meta-spacer-block: 0.5ch; --meta-spacer-inline: 0.75ch; align-items: center; color: var(--card-text-color); display: grid; font-size: var(--text-x-small); font-weight: 500; grid-template-areas: "avatars-author text-added text-updated avatars-assignees" "avatars-author text-author text-assignees avatars-assignees"; grid-template-columns: auto auto 1fr auto; inline-size: fit-content; text-transform: uppercase; strong, .local-time-value { font-weight: 900; } } /* Assign grid areas */ .card__meta-avatars--author { grid-area: avatars-author; } .card__meta-avatars--assignees { grid-area: avatars-assignees; } .card__meta-text--added { grid-area: text-added; } .card__meta-text--author { grid-area: text-author; } .card__meta-text--updated { grid-area: text-updated; } .card__meta-text--assignees { grid-area: text-assignees; } .card__meta-avatars { align-self: center; } .card__meta-avatars--author { margin-inline-end: var(--meta-spacer-inline); } .card__meta-avatars--assignees { display: flex; margin-inline-start: var(--meta-spacer-inline); .avatar { margin-inline-end: calc(-1 * var(--meta-spacer-inline)); } } .card__assignees-trigger { background: transparent; border: none; padding: 0; display: flex; &:focus-visible { outline: none; .btn { box-shadow: 0 0 0 var(--focus-ring-size) var(--focus-ring-color); } } } .card__meta-text { line-height: 1; white-space: nowrap; .icon { --icon-size: 0.9em; margin-inline-end: 0.5ch; vertical-align: top; } } /* Top */ .card__meta-text:nth-of-type(odd) { border-block-end: var(--card-border); padding-block-end: var(--meta-spacer-block); } /* Bottom */ .card__meta-text:nth-of-type(even) { padding-block-start: var(--meta-spacer-block); } /* Left */ .card__meta-text:nth-of-type(-n+2) { border-inline-end: var(--card-border); padding-inline-end: var(--meta-spacer-inline); } /* Right */ .card__meta-text:nth-of-type(n+3) { padding-inline-start: var(--meta-spacer-inline); } @media (max-width: 639px) { .card__meta { inline-size: 100%; } .card__meta-avatars--author, .card__meta-avatars--assignees .avatar { display: none; } } /* Closed stamp /* ------------------------------------------------------------------------ */ .card__closed { --stamp-color: oklch(var(--lch-green-medium) / 0.65); align-items: center; backdrop-filter: blur(2px); background-color: color-mix(in srgb, var(--card-bg-color) 90%, transparent); border-radius: 0.2em; border: 0.5ch solid var(--stamp-color); color: var(--color-ink-dark); display: flex; flex-direction: column; font-weight: bold; inset: auto 0 -1lh auto; justify-content: center; max-inline-size: 25ch; min-inline-size: 16ch; padding: 1ch; pointer-events: none; position: absolute; rotate: 5deg; transform-origin: top right; z-index: 2; .cards & { display: none; .cards--grid &, .cards--on-deck & { &.card__closed--system { display: flex; } } } } .card:has(.card__closed), .card:is(.card--postponed), .card-perma:has(.card__closed), .card-perma:has(.card--postponed) { --card-color: var(--color-card-complete) !important; .bubble { display: none; } } .card__closed-title { color: var(--stamp-color); font-size: 1.3em; font-weight: 900; position: relative; text-align: center; text-transform: uppercase; } .card__closed-date { font-family: var(--font-mono); text-transform: uppercase; } .card__closed-by { border-block-end: 1px dashed currentcolor; } /* Misc bits /* ------------------------------------------------------------------------ */ .card__background { inset: 0; position: absolute; z-index: -1; img { block-size: 100%; border-radius: var(--border-radius); inline-size: 100%; object-fit: cover; object-position: center; opacity: 1; transition: filter 0.2s ease-in-out, opacity 0.2s ease-in-out; } } .card__link { content: ""; inset: 0; position: absolute; z-index: -1; } .card:nth-child(2n+1) .bubble { --bubble-rotate: -90deg; } .card:nth-child(3n+1) .bubble { --bubble-rotate: 45deg; } /* Variants /* ------------------------------------------------------------------------ */ .card--notification { --card-color: var(--color-card-default); --card-padding-inline: 1ch; --card-padding-block: 1ch; background-color: var(--color-canvas); color: var(--color-ink); &.card--closed { --card-color: var(--color-card-complete) !important; } .card__body { padding-block-end: 0; } .card__board { font-size: var(--text-xx-small); padding-block: 0.5ch; padding-inline-start: var(--inline-space-double); } .card__header { margin-block-start: calc(-1.1 * var(--card-padding-block)); margin-inline: calc(-1 * var(--card-padding-inline)); max-inline-size: unset; } .card__timestamp { opacity: 0.66; } .card__notification-body { font-size: var(--text-x-small); } .card__notification-meta { font-size: var(--text-xx-small); font-weight: 600; text-transform: uppercase; } .card__notification-mentioner { background-color: var(--color-highlight); border-radius: 0.7em 0.2em 0.7em 0.2em; color: inherit; display: inline-flex; padding: 0.1em 0.3em; } .card__title { font-size: var(--text-small); font-weight: bold; min-block-size: 0; } } .card__notification-unread-indicator { --btn-background: var(--color-marker); --btn-border-color: var(--color-canvas); --btn-color: var(--color-ink-inverted); --btn-icon-size: 0.5em; --btn-padding: 0; --btn-size: 1.6em; font-size: var(--text-xx-small); font-weight: 600; margin: 2px; position: relative; z-index: 1; .icon { opacity: 0; transition: opacity 150ms ease; } @media (hover: hover) { .card:hover & { --btn-background: var(--color-ink-lightest); --btn-color: var(--color-ink); .badge-count { opacity: 0; } .icon { opacity: 1; } } } } .card__board-public-description { max-inline-size: 66ch; > *:first-child { margin-block-start: 0; } > *:last-child { margin-block-end: 0; } ul, ol { inline-size: fit-content; margin-inline: auto; text-align: start; } code { text-align: left; } } } ================================================ FILE: app/assets/stylesheets/circled-text.css ================================================ @layer components { .circled-text { --circled-color: oklch(var(--lch-blue-dark)); --circled-padding: -0.5ch; background: none; color: var(--circled-color); position: relative; white-space: nowrap; span { opacity: 0.5; mix-blend-mode: multiply; html[data-theme="dark"] & { mix-blend-mode: screen; } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { mix-blend-mode: screen; } } } span::before, span::after { border: 2px solid var(--circled-color); content: ""; inset: var(--circled-padding); position: absolute; } span::before { border-inline-end: none; border-radius: 100% 0 0 75% / 50% 0 0 50%; inset-block-start: calc(var(--circled-padding) / 2); inset-inline-end: 50%; } span::after { border-inline-start: none; border-radius: 0 100% 75% 0 / 0 50% 50% 0; inset-inline-start: 30%; } } } ================================================ FILE: app/assets/stylesheets/color-picker.css ================================================ @layer components { .color-picker__colors { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--inline-space-half); .btn { --btn-border-radius: 0.1em; --btn-size: 2em; --icon-size: 1.3em; inline-size: 100%; } } } ================================================ FILE: app/assets/stylesheets/comments.css ================================================ @layer components { .comments { --avatar-size: 2.33em; --comment-padding-block: var(--block-space-half); --comment-padding-inline: var(--inline-space-double); --comment-max: 70ch; --reaction-size: 2.25rem; display: flex; flex-direction: column; padding-inline: var(--inline-space); place-items: center; text-align: center; @media (min-width: 160ch) { padding-inline: var(--tray-size); } @media (min-width: 640px) { --reaction-size: 1.6875rem; } } .comments__subscribers { max-inline-size: var(--comment-max); padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double)); } .comment { /* Distinguish from the .comment class used for code formatting without extra specificity */ &:where(.comments &) { display: flex; margin-inline: auto; max-inline-size: var(--comment-max); position: relative; } .comment-by-system & { --comment-padding-block: var(--block-space-half); text-align: center; &::before { /* Make up space for lack of avatar */ content: ""; display: flex; inline-size: calc(var(--comment-padding-inline) * 0.9); } .comment__avatar { display: none; } .comment__author { a { margin: 0 auto; } h3 { margin-inline: auto; } strong { display: none; } } .comment__body { padding: 0; text-align: center; } .comment__content { --stripe-color: var(--color-ink-lightest); background-image: repeating-linear-gradient( 45deg in srgb, var(--color-canvas) 0 1px, var(--stripe-color) 1px 10px); padding-inline: var(--comment-padding-inline); .comments--system-expanded .comment-by-system & { --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); } } .reactions { display: none !important; } } .reactions { margin-block-start: var(--block-space-half); margin-inline: calc(var(--column-gap) / -1); &:not(:has(.reaction)) { inset-block-end: var(--comment-padding-block); inset-inline-end: calc(var(--comment-padding-inline) / 2); margin: 0; position: absolute; @media (max-width: 640px) { inset-inline-end: calc(var(--comment-padding-inline) / 3); } } } } .comment__author { .btn { font-weight: inherit; } @media (max-width: 639px) { margin-block-end: calc(var(--block-space-half) / 2); h3 { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 0.4em; } } } .comment__avatar { margin: calc(var(--comment-padding-block) * 0.75) calc(var(--comment-padding-inline) * -0.75); z-index: 0; } .comment__body { text-align: start; .action-text-content { > action-text-attachment:first-child figure { margin-block-start: 0.5ch; } > :last-child { margin-block-end: 0; } } &:not:has(lexxy-editor) { padding-inline-end: var(--reaction-size); } /* Add an empty space so the last line of text doesn't overlap with the reaction button */ .action-text-content > p:last-child::after { content: ""; display: inline-block; inline-size: var(--reaction-size); } } .comment__content { --btn-icon-size: 1.2rem; --btn-size: var(--reaction-size); --comment-bg-color: var(--color-ink-lightest); --lexxy-bg-color: var(--comment-bg-color); background-color: var(--comment-bg-color); border-radius: 0.2em; max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75)); padding: var(--comment-padding-block) calc(var(--comment-padding-inline) / 2) calc(var(--comment-padding-block) * 1.5) var(--comment-padding-inline); word-wrap: break-word; } .comment__edit { background-color: var(--color-ink-lightest); &:hover { z-index: 1; } } .comment__permalink-title { color: currentColor; opacity: 0.66; text-decoration: none; text-transform: capitalize; @media (max-width: 639px) { font-size: var(--text-small); } } .comment__history { background-color: transparent; display: none; inset: var(--comment-padding-block) var(--comment-padding-block) auto auto; translate: 2px -2px; /* Align baseline with time stamp */ position: absolute; @media (any-hover: hover) { &:hover { background-color: var(--stripe-color); } } } .comment-by-system { display: none; transition: var(--dialog-duration) allow-discrete; transition-property: display; .comments--system-expanded & { display: contents; } } /* Show the last system comment */ :nth-last-child(1 of .comment-by-system) { display: contents; .comment__history { display: inline-flex; } } /* Hide the "Show history" button if there's only one system comment */ :nth-child(1 of .comment-by-system) { .comment__history { display: none; } } .comment-by-system--account-limit { --stripe-color: oklch(var(--lch-blue-lightest)); .comment__content { padding: 3ch; } } } ================================================ FILE: app/assets/stylesheets/credentials.css ================================================ @layer components { .credential { border-block-start: var(--border); list-style: none; &:last-child { border-block-end: var(--border); } } .credential__link { align-items: center; block-size: 1.75lh; color: currentcolor; display: flex; gap: 1ch; padding-inline: 1ch; @media (any-hover: hover) { &:hover { background: var(--color-ink-lightest); .credential__arrow { opacity: 0.66; } } } } .credential__arrow { margin-inline-start: auto; opacity: 0; } [data-passkey-errors] [data-passkey-error] { display: none; } [data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"], [data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { display: block; } } ================================================ FILE: app/assets/stylesheets/dialog.css ================================================ @layer components { /* Prevent page scrolling when modal dialog is open */ html:has(dialog:modal) { overflow: hidden; } :is(.dialog) { border: 0; opacity: 0; transform: scale(0.85); transform-origin: center; transition-behavior: allow-discrete; transition-duration: calc(var(--dialog-duration) / 2); /* Faster closing */ transition-property: display, opacity, overlay, transform; transition-timing-function: ease-out; &::backdrop { background-color: var(--color-black); opacity: 0; transition-behavior: allow-discrete; transition-duration: calc(var(--dialog-duration) / 2); transition-property: display, opacity, overlay; transition-timing-function: ease-out; } &[open] { opacity: 1; transform: scale(1); transition-duration: var(--dialog-duration); /* Normal opening speed */ &::backdrop { opacity: 0.5; transition-duration: var(--dialog-duration); } } @starting-style { &[open] { opacity: 0; transform: scale(0.85); } &[open]::backdrop { opacity: 0; } } } /* Ensure padding from viewport edges */ .dialog.panel { max-inline-size: calc(100vw - var(--inline-space-double) * 2); } } ================================================ FILE: app/assets/stylesheets/dividers.css ================================================ @layer components { .divider { --divider-color: var(--color-ink-light); align-items: center; display: flex; gap: var(--inline-space); &:before, &:after { background: var(--divider-color); block-size: var(--divider-size, 1px); content: ""; flex: 1; } } .divider--fade { &:before { background: linear-gradient(to right, transparent, var(--divider-color) 50%); } &:after { background: linear-gradient(to left, transparent, var(--divider-color) 50%); } } } ================================================ FILE: app/assets/stylesheets/drag_and_drop.css ================================================ @layer components { .drag-and-drop__dragged-item { box-shadow: none; filter: grayscale(1) brightness(0.97); opacity: 0.6; outline: 2px dashed var(--color-selected-dark); } .drag-and-drop__hover-container { --dnd-bg-color: var(--color-selected-light); --dnd-border-color: var(--color-selected-dark); background-color: var(--dnd-bg-color); outline: 2px dashed var(--dnd-border-color); outline-offset: -2px; transition: background-color 200ms; z-index: 1; } } ================================================ FILE: app/assets/stylesheets/events.css ================================================ @layer components { /* Events header /* ------------------------------------------------------------------------ */ .header--events { --header-button-count: 1; @media (min-width: 640px) { --header-actions-width: 7.25rem !important; } } /* Event column layout /* ------------------------------------------------------------------------ */ .events { --events-gap: 1ch; --events-border: 1px solid var(--color-ink-lighter); --events-day-header-height: 1.75rem; } .events--grid { --events-grid-gap: 1rem; --events-grid-columns: 1; container-type: inline-size; display: flex; flex-direction: row; flex-wrap: wrap; gap: var(--events-grid-gap); justify-content: center; margin: var(--block-space) auto; max-inline-size: var(--main-width); @media (min-width: 640px) { --events-grid-columns: 2; } @media (min-width: 960px) { --events-grid-columns: 3; } .event { inline-size: calc((100% - var(--events-grid-gap) * (var(--events-grid-columns) - 1) ) / var(--events-grid-columns)) !important; margin: 0 !important; } } .events__activity-summary { border: solid var(--color-ink-lighter); border-width: 1px 1px 0 1px; color: var(--color-ink-darker); inline-size: auto; margin-inline: auto; padding: 1.1lh 1lh 1lh; position: relative; text-align: start; z-index: 2; .events section:first-of-type & { border-radius: 0.5em 0.5em 0 0; } &:has(.events__activity--generating) { --border-color: var(--color-selected-dark); animation: gradient 4s ease infinite; background: linear-gradient(-45deg, var(--color-gradient-1), var(--color-gradient-2), var(--color-gradient-3), var(--color-gradient-4)); background-size: 300%; text-align: center; } > * { column-count: 2; column-gap: var(--inline-space-double); margin-inline: auto; max-inline-size: 80ch; } a { color: inherit; } h3 { column-span: all; font-size: var(--text-large); line-height: 1.3; margin-block: 0.5em 0.25em; text-align: center; text-wrap: balance; + p { column-span: all; font-size: var(--text-medium); margin-block: 0 1.5em; text-align: center; text-wrap: balance; } } h4 { break-after: avoid-column; font-size: var(--text-medium); line-height: 1.3; margin-block: 0.5em 0.25em; text-wrap: balance; + p { break-inside: avoid-column; margin-block: 0 1.5em; } } hr { display: none; } } .events__activity-generating-msg { display: block; font-weight: 500; margin-block: 2lh; opacity: 0.5; } .events__activity-prompt-edit { inset: auto 1em 1em auto; position: absolute; } .events__filter-select { font-weight: inherit; text-decoration: underline; @media (any-hover: hover) { &:hover { --btn-color: var(--color-link); } } } .events__day { position: relative; @media (max-width: 639px) { margin-block-end: calc(var(--events-gap) * 2); } } .events__day-header, .events__column-header { font-size: var(--text-small); text-align: center; text-transform: uppercase; } .events__day-header { block-size: 0; margin-block-start: calc(var(--events-day-header-height) / 2); position: relative; z-index: var(--z-events-day-header); } .events__day-time { align-items: center; background-color: var(--color-ink); block-size: var(--events-day-header-height); border-radius: 0.2em; color: var(--color-ink-inverted); display: flex; gap: 0.4ch; inline-size: fit-content; inset: 0 auto auto 50%; margin-inline: auto; padding-inline: 1.5ch; position: absolute; translate: -50% -50%; } .events__columns { border-block-start: var(--events-border); position: relative; z-index: 1; @media (min-width: 640px) { align-items: end; border-inline: var(--events-border); display: grid; grid-template-columns: repeat(3, 1fr); /* Pseudo column borders since .events__column is display: contents */ &:before, &:after { border-inline-start: var(--events-border); content: ""; inset-block: 0; position: absolute; z-index: var(--z-events-day-header); } &:before { inset-inline-start: calc(100% / 3); } &:after { inset-inline-end: calc(100% / 3); } } } .events__column { @media (max-width: 639px) { &:not(:has(.event)) { display: none; } } @media (min-width: 640px) { display: contents; } &:nth-of-type(1) > * { grid-column-start: 1; } &:nth-of-type(2) > * { grid-column-start: 2; } &:nth-of-type(3) > * { grid-column-start: 3; } } .events__column-header { background-color: var(--color-canvas); grid-row-start: 1; inset-block-start: var(--custom-safe-inset-top); margin-block: calc(var(--events-gap) * 2) var(--events-gap); padding-block: var(--events-gap); position: sticky; z-index: var(--z-events-column-header); @media (max-width: 639px) { margin-inline: calc(var(--main-padding) * -0.5); padding-inline: var(--main-padding); } } .events__column-footer { grid-row-start: 26; margin: var(--events-gap); padding: var(--block-space-half) var(--inline-space); } .events__maximize-button { inset: 50% var(--events-gap) auto auto; outline-offset: -2px; position: absolute; transform: translateY(-50%); z-index: 1; @media (max-width: 639px) { inset-inline-end: 0; } @media (any-hover: hover ) { opacity: 0; .events__column-header:hover &, &:focus-visible { opacity: 1; } } } .events__time-block { align-content: end; display: grid; gap: var(--events-gap); justify-items: center; margin: 0; padding: 0; @media (min-width: 640px) { padding-block-end: calc(var(--events-gap) * 3); padding-inline: calc(var(--events-gap) * 2); } .event { grid-column-start: unset !important; grid-row-start: unset !important; } } .events__none { background-color: var(--color-canvas); border-block-start: var(--events-border); grid-column: 1 / -1; padding-block: 3em; text-align: center; } /* Event /* ------------------------------------------------------------------------ */ .event { --column-gap: 0.7ch; --event-padding: 0.6em; background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); border-radius: 0.2em; box-shadow: 0 0 0 1px color-mix(in srgb, var(--card-color) 20%, var(--color-canvas)); color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); margin: var(--inline-space); max-inline-size: 100%; min-inline-size: 0; overflow: clip; padding: var(--event-padding); position: relative; z-index: 0; @media (max-width: 639px) { inline-size: 100%; } &:has(.card__background img:not([src=""])) { background-color: var(--color-canvas) !important; } .event_attachments { .attachment--image { block-size: auto; max-inline-size: 30%; } } .card__background { filter: brightness(1.2) contrast(0.8); opacity: 0.2; z-index: 0; } .card__header { inline-size: 100%; margin-block-start: calc(-1 * var(--event-padding)); } .card__board { background-color: transparent; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); font-size: 0.7em; } .card__board-name { border-inline-start: 1px solid color-mix(in srgb, var(--color-ink) 33%, var(--color-canvas)); color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); margin: 0 0 0 calc(var(--inline-space) * 0.75); padding-inline-start: calc(var(--inline-space) * 0.75); } .card__header { overflow: visible; } .card__id { margin: 0; } } .event--related { outline: 0.15rem solid var(--card-color); } .event__content { max-inline-size: 100%; position: relative; z-index: 1; } .event__grid-item { background-color: var(--color-canvas); block-size: 100%; border-radius: 0; display: flex; inline-size: 100%; } .event__grid-column-title { --z: 3; background-color: var(--color-canvas); font-size: 0.9em; padding: 1.5em 0 1em; text-transform: uppercase; } .event__icon { color: var(--card-color); display: grid; margin-inline-start: auto; place-content: center; translate: calc(var(--event-padding) / 2); } .event__timestamp { align-self: start; display: grid; font-weight: 600; margin-block-end: var(--block-space-half); } .event__title { --lines: 4; font-size: 1.1em; line-height: 1.2; } } ================================================ FILE: app/assets/stylesheets/expandable.css ================================================ @layer components { .expandable-on-native { body:not([data-platform~=native]) & { &::details-content { display: contents; } summary { display: none; } } } } ================================================ FILE: app/assets/stylesheets/filters.css ================================================ @layer components { #header:has(.filters) { position: relative; } .filters { align-items: center; display: flex; flex-wrap: wrap; gap: var(--inline-space-half); justify-content: center; padding-block-start: 2px; /* prevents input focus-ring clipping on mobile */ position: relative; view-transition-name: filters; z-index: 1; .btn { --btn-border-color: var(--color-ink-medium); --input-background: var(--color-canvas); } &:has(dialog[open]), &:has([data-controller~="tooltip"]:hover) { z-index: calc(var(--z-nav) + 1); } } .filter { &[aria-selected] { display: flex; } } .filter__button { --btn-border-size: 0; --btn-font-weight: 400; --btn-icon-size: 0.7em; --btn-padding: 0.3em 0.7em; inline-size: 100%; justify-content: space-between; text-align: start; span { overflow: hidden; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; } img { display: none; } &:has(input[type=checkbox]:checked) img { display: block; } } .filter__columns { display: grid; grid-template-columns: repeat(5, 1fr); max-block-size: 50dvh; } .filter__label { display: flex; inline-size: 100%; padding: 0.3em 0.7em; strong { overflow: hidden; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; } } .filter__menu { display: flex; flex-direction: column; inline-size: 100%; list-style: none; margin: 0; min-inline-size: 0; overflow-x: auto; padding: 0 var(--inline-space); position: relative; row-gap: 0.2em; &::before { block-size: 100%; border-block: 0; border-inline-end: 0; border-inline-start: 1px solid var(--color-ink-lighter); content: ""; display: inline-flex; inline-size: 0; position: absolute; inset: 0 auto 0 0; } &:first-child::before { display: none; } li { text-align: start; } } .filter__terms:is(.input) { --input-background: var(--color-canvas); --input-border-radius: 5em; --input-padding: 0.5em 1.3em; --input-width: 16em; --collapsed-filter-space: calc(var(--btn-size) + var(--inline-space-half) + 0.25em); inline-size: var(--input-width); min-inline-size: var(--input-width); .filters:not(.filters--expanded, .filters--has-filters-set) & { --input-padding: 0.5em 2.7em 0.5em 1.3em; inline-size: calc(var(--input-width) + (0.25 * var(--collapsed-filter-space))); margin-inline-end: calc((var(--btn-size) + var(--inline-space-half) + 0.25em) * -1); min-inline-size: calc(var(--input-width) + (0.25 * var(--collapsed-filter-space))); } } .filter-toggle { .filters:not(.filters--expanded, .filters--has-filters-set) & { --btn-background: transparent; --btn-border-size: 0; transform: translateX(calc(var(--inline-space-half) * -1)); position: relative; } } .quick-filter { position: relative; &:has([aria-checked="true"]):not(.quick-filter--with-default) { .input--select { --input-background: var(--color-selected); } } /* Hide a quick filter if there's nothing in it to filter by */ &:not(:has(.popup__item)) { display: none !important; } } .filters:not(.filters--expanded) { .quick-filter:not([data-filter-show=true]) { display: none; } } .filters.filters--expanded { .quick-filter { display: block; } } .filters__manage { display: none; } .filters--has-filters-set .filters__manage { display: flex; } .filters__show-when-expanded { .filters:not(.filters--expanded) & { display: none; } } .filters__show-when-collapsed { .filters--expanded & { display: none; } } } ================================================ FILE: app/assets/stylesheets/flash.css ================================================ @layer components { .flash { display: flex; inset-block-start: calc(var(--block-space) + var(--custom-safe-inset-top)); inset-inline-start: 50%; justify-content: center; position: fixed; transform: translate(-50%); z-index: var(--z-flash); } .flash__inner { animation: appear-then-fade 3s 300ms both; background-color: var(--flash-background, var(--color-ink)); border-radius: 4em; color: var(--flash-color, var(--color-ink-inverted)); display: inline-flex; font-size: var(--font-size-medium); inline-size: max-content; margin: 0 auto; max-inline-size: 90vw; padding: 0.7em 1.4em; } } ================================================ FILE: app/assets/stylesheets/font-face.css ================================================ @layer base { /* Segoe UI Variable Fizzy font face configuration. 1. Segoe UI Variable (Weights 100-700): Leverages variable font features to: - Automatically adjust Weight (wght) dynamically within the 100-700 range. - Automatically manage Optical Size (opsz) based on font-size. 2. Segoe UI Black (Weights 800-900): Used as a fallback because Segoe UI Variable does not natively support 900 weight. This ensures a consistent bold experience across all weights. */ @font-face { font-family: "Segoe UI Variable Fizzy"; src: local("Segoe UI Variable"); font-weight: 100 700; font-style: normal; } @font-face { font-family: "Segoe UI Variable Fizzy"; src: local("Segoe UI Variable"); font-weight: 100 700; font-style: italic; } @font-face { font-family: "Segoe UI Variable Fizzy"; src: local("Segoe UI Black"); font-weight: 800 900; font-style: normal; } @font-face { font-family: "Segoe UI Variable Fizzy"; src: local("Segoe UI Black Italic"); font-weight: 800 900; font-style: italic; } } ================================================ FILE: app/assets/stylesheets/golden-effect.css ================================================ @layer components { .golden-effect { /* Uncomment below to use the card color for golden effect */ /* --color-golden: color-mix(in srgb, var(--card-color) 35%, transparent); */ background-color: color-mix(in srgb, var(--color-golden) 4%, var(--color-canvas)); background-image: linear-gradient(60deg, color-mix(in srgb, var(--color-golden) 45%, transparent) 0%, color-mix(in srgb, var(--color-golden) 10%, transparent) 33%, color-mix(in srgb, var(--color-golden) 5%, transparent) 66%, color-mix(in srgb, var(--color-golden) 45%, transparent) 100% ); box-shadow: 0 0 0 1px color-mix(in oklch, var(--color-golden) 100%, transparent), 0 0 0.2em 0.2em color-mix(in oklch, var(--color-golden) 25%, transparent), 0 0 1em 0.5em color-mix(in oklch, var(--color-golden) 25%, transparent); lexxy-toolbar { --lexxy-bg-color: transparent; } } } ================================================ FILE: app/assets/stylesheets/header.css ================================================ @layer components { /* Centered title with space for two buttons on either side */ .header { --header-gap: 0.5ch; --btn-icon-size: 1rem; --header-btn-size: 2rem; --header-button-count: 0; --header-actions-width: calc((var(--header-btn-size) + var(--header-gap)) * var(--header-button-count)); display: grid; grid-template-columns: var(--header-actions-width) 1fr var(--header-actions-width); grid-template-areas: "menu menu menu" "actions-start title actions-end"; max-inline-size: 100dvw; padding-block: calc(var(--block-space-half) + var(--custom-safe-inset-top)) var(--block-space-half); padding-inline: var(--main-padding); position: relative; z-index: var(--z-nav); /* Change the grid size depending on how many buttons are present */ &:has(.header__actions > *:nth-child(1)) { --header-button-count: 1; } &:has(.header__actions > *:nth-child(2)) { --header-button-count: 2; } &:has(.header__actions > *:nth-child(3)) { --header-button-count: 3; } &:has(nav) { row-gap: 0; } &:has(dialog[open]) { z-index: var(--z-nav-open); } &:has(~ #main .card-columns) { inline-size: 100dvw; margin-inline: auto; max-inline-size: var(--main-width); } nav { grid-area: menu; margin-inline: auto; } } .header__actions { display: flex; font-size: var(--text-x-small); gap: var(--header-gap); inline-size: var(--header-actions-width); } .header__actions--start { grid-area: actions-start; margin-inline-end: auto; } .header__actions--end { grid-area: actions-end; justify-content: flex-end; margin-inline-start: auto; } .header__title { color: inherit; font-size: var(--text-large); font-weight: 900; grid-area: title; margin: 0 auto; min-inline-size: 0; text-align: center; } .header__skip-navigation { --left-offset: -999em; inset-block-start: 4rem; inset-inline-start: var(--left-offset); position: absolute; white-space: nowrap; z-index: 11; &:focus { --left-offset: var(--inline-space); } } .header__logo { color: var(--color-ink); font-size: 1.2rem; inline-size: auto; margin-block-start: 0.1em; span { background: var(--color-ink-lightest); block-size: auto; border-radius: 0.3125em; box-shadow: 0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1), 0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05), 0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05), 0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05) ; display: grid; height: 1.5em; inline-size: 1.5em; padding: 0.325em 0.275em 0.225em 0.275em; place-content: center; width: 1.5em; } svg { height: 100%; margin-inline-start: 0.4125em; margin-inline-end: 0.5375em; max-height: 0.8625em; overflow: visible; width: auto; } } /* Optional class to stack header actions on small screens /* ------------------------------------------------------------------------ */ .header--mobile-actions-stack { @media (max-width: 639px) { grid-template-areas: "actions-start menu actions-end" "title title title"; .header__title { margin-block-start: 0.25rem; } } } } ================================================ FILE: app/assets/stylesheets/icons.css ================================================ @layer components { .icon { -webkit-touch-callout: none; background-color: currentColor; block-size: var(--icon-size, 1em); display: inline-block; flex-shrink: 0; inline-size: var(--icon-size, 1em); mask-image: var(--svg); mask-position: center; mask-repeat: no-repeat; mask-size: var(--icon-size, 1em); pointer-events: none; user-select: none; } img.icon { background: none; } .icon--37signals { --svg: url("37signals.svg"); } .icon--add { --svg: url("add.svg "); } .icon--add--meta { --svg: url("add--meta.svg "); } .icon--arrow-left { --svg: url("arrow-left.svg "); } .icon--arrow-right { --svg: url("arrow-right.svg "); } .icon--arrow-up { --svg: url("arrow-up.svg "); } .icon--art { --svg: url("art.svg "); } .icon--assigned { --svg: url("assigned.svg "); } .icon--attachment { --svg: url("attachment.svg "); } .icon--authentication { --svg: url("authentication.svg "); } .icon--bell-alert { --svg: url("bell-alert.svg "); } .icon--bell-off { --svg: url("bell-off.svg "); } .icon--bell { --svg: url("bell.svg "); } .icon--bolt { --svg: url("bolt.svg "); } .icon--bookmark-outline { --svg: url("bookmark-outline.svg "); } .icon--bookmark { --svg: url("bookmark.svg "); } .icon--boost { --svg: url("boost.svg "); } .icon--camera { --svg: url("camera.svg "); } .icon--caret-down { --svg: url("caret-down.svg "); } .icon--check { --svg: url("check.svg "); } .icon--check-circle { --svg: url("check-circle.svg "); } .icon--check-all { --svg: url("check-all.svg "); } .icon--clipboard { --svg: url("clipboard.svg "); } .icon--close { --svg: url("close.svg "); } .icon--close-circle { --svg: url("close-circle.svg "); } .icon--collapse { --svg: url("collapse.svg "); } .icon--board { --svg: url("board.svg "); } .icon--board-add { --svg: url("board-add.svg "); } .icon--column-left { --svg: url("column-left.svg "); } .icon--column-right { --svg: url("column-right.svg "); } .icon--comment { --svg: url("comment.svg "); } .icon--copy-paste { --svg: url("copy-paste.svg "); } .icon--crown { --svg: url("crown.svg "); } .icon--email { --svg: url("email.svg "); } .icon--everyone { --svg: url("everyone.svg "); } .icon--expand { --svg: url("expand.svg "); } .icon--gear { --svg: url("gear.svg "); } .icon--grid { --svg: url("grid.svg "); } .icon--filter { --svg: url("filter.svg "); } .icon--fizzy { --svg: url("fizzy.svg"); } .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } .icon--logout { --svg: url("logout.svg "); } .icon--marker { --svg: url("marker.svg "); } .icon--maximize { --svg: url("maximize.svg "); } .icon--menu { --svg: url("menu.svg "); } .icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); } .icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); } .icon--minus { --svg: url("minus.svg "); } .icon--monitor { --svg: url("monitor.svg "); } .icon--moon { --svg: url("moon.svg "); } .icon--move { --svg: url("move.svg "); } .icon--notification-bell-access-only { --svg: url("bell.svg "); } .icon--notification-bell-watching { --svg: url("bell-off.svg "); } .icon--notification-bell-reverse-access-only { --svg: url("bell-off.svg "); } .icon--notification-bell-reverse-watching { --svg: url("bell.svg "); } .icon--password { --svg: url("password.svg "); } .icon--pencil { --svg: url("pencil.svg "); } .icon--person { --svg: url("person.svg "); } .icon--person-add { --svg: url("person-add.svg "); } .icon--picture-add { --svg: url("picture-add.svg "); } .icon--picture-double { --svg: url("picture-double.svg "); } .icon--picture-remove { --svg: url("picture-remove.svg "); } .icon--picture-zoom { --svg: url("picture-zoom.svg "); } .icon--pinned { --svg: url("pinned.svg "); } .icon--qr-code { --svg: url("qr-code.svg "); } .icon--reaction { --svg: url("reaction.svg "); } .icon--refresh { --svg: url("refresh.svg "); } .icon--refresh--meta { --svg: url("refresh--meta.svg "); } .icon--remove { --svg: url("remove.svg "); } .icon--rename { --svg: url("rename.svg "); } .icon--search { --svg: url("search.svg "); } .icon--settings { --svg: url("settings.svg "); } .icon--share { --svg: url("share.svg "); } .icon--sliders { --svg: url("sliders.svg "); } .icon--sun { --svg: url("sun.svg "); } .icon--switch { --svg: url("switch.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--tag-outline { --svg: url("tag-outline.svg "); } .icon--thumb-up { --svg: url("thumb-up.svg "); } .icon--trash { --svg: url("trash.svg "); } .icon--unpinned { --svg: url("unpinned.svg"); } .icon--unseen { --svg: url("unseen.svg"); } .icon--world { --svg: url("world.svg"); } .icon--youtube { --svg: url("youtube.svg"); } } ================================================ FILE: app/assets/stylesheets/import.css ================================================ @layer components { .import-status { --import-status-border-color: var(--color-ink-light); --import-status-color: var(--color-ink); border: 1px dashed var(--import-status-border-color); border-radius: 1ch; color: var(--import-status-color); font-size: var(--text-medium); padding: 1.5ch; .btn { font-size: var(--text-small); margin-block-start: 1.5ch; } } .import-status--success { --import-status-border-color: var(--color-positive); --import-status-color: var(--color-positive); } .import-status--error { --import-status-border-color: var(--color-negative); --import-status-color: var(--color-negative); } @keyframes dash { to { background-position: 100% 0%, 0% 100%, 0% 0%, 100% 100%; } } } ================================================ FILE: app/assets/stylesheets/inputs.css ================================================ @layer components { /* Text inputs */ .input { accent-color: var(--input-accent-color, var(--color-ink)); background-color: var(--input-background, transparent); border-radius: var(--input-border-radius, 0.5em); border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-ink-medium)); color: var(--input-color, var(--color-ink)); font-size: max(16px, 1em); inline-size: 100%; line-height: inherit; max-inline-size: 100%; padding: var(--input-padding, 0.5em 0.8em); resize: none; &:autofill, &:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus { -webkit-text-fill-color: var(--color-ink); -webkit-box-shadow: 0 0 0px 1000px var(--color-selected) inset; } &:where(:focus) { --focus-ring-offset: -1px; } &[readonly] { --focus-ring-size: 0; } &[autocomplete='one-time-code'] { --input-spacing: 0.5em; font-family: var(--font-mono); font-size: var(--text-large); font-weight: 900; inline-size: 18ch; letter-spacing: 1ch; min-inline-size: 18ch; text-align: center; } &[type='number'] { &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } } /* Target mobile Safari only */ @supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) { @media (hover: none) { font-size: max(16px, 1em) !important; } } } .input--file, .input--upload { cursor: pointer; &:has(input[type="file"]:focus-visible) { outline: 0.15rem solid var(--color-selected-dark); } input[type="file"] { --hover-size: 0; --input-border-color: transparent; --input-border-radius: 8px; block-size: 100%; cursor: pointer; font-size: 0; inline-size: 100%; overflow: clip; &::file-selector-button { appearance: none; cursor: pointer; opacity: 0; } } } .input--file { display: grid; inline-size: auto; place-items: center; > * { grid-area: 1 / 1; } img { border-radius: 0.4em; } &:is(.avatar) { input[type="file"] { border-radius: 50%; } } } .input--upload { --btn-border-color: var(--color-ink); --btn-border-radius: 1ch; border-style: dashed; position: relative; input[type="file"] { inset: 0; outline: none; position: absolute; } &:has([data-upload-preview-target="fileName"]:not([hidden])) { --btn-border-color: var(--color-positive); --btn-color: var(--color-positive); } } .input--select { --input-border-radius: 2em; --input-padding: 0.5em 1.8em 0.5em 1.2em; --caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); --caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); -webkit-appearance: none; appearance: none; background-image: var(--caret-icon); background-size: 0.5em; background-position: center right 0.9em; background-repeat: no-repeat; text-align: start; html[data-theme="dark"] & { --caret-icon: var(--caret-icon-dark); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { --caret-icon: var(--caret-icon-dark); } } option { background-color: var(--color-canvas); color: var(--color-ink); } } .input--textarea { --input-padding: 0; line-height: inherit; min-block-size: calc(3lh + (2 * var(--input-padding))); min-inline-size: 100%; padding-block: var(--input-padding); padding-inline: calc(var(--input-padding) + calc((1lh - 1ex) / 2)); @supports (field-sizing: content) { field-sizing: content; max-block-size: calc(3lh + (2 * var(--input-padding))); min-block-size: calc(1lh + (2 * var(--input-padding))); } } .input--invisible { background-color: transparent; block-size: 5px; border: none; inline-size: 5px; opacity: 0.1; &:focus { outline: none; } } /* Switches */ .switch { --switch-color: var(--color-ink-medium); --switch-hover-brightness: 0.9; block-size: 1.75em; border-radius: 2em; display: inline-flex; inline-size: 3em; position: relative; &:has(:focus-visible) { .switch__btn { outline: var(--focus-ring-size) solid var(--focus-ring-color); } } } .switch__input { block-size: 0; inline-size: 0; opacity: 0.1; } .switch__btn { background-color: var(--switch-color); border-radius: 2em; cursor: pointer; inset: 0; outline-offset: var(--focus-ring-offset); position: absolute; transition: 150ms ease; &::before { background-color: var(--color-ink-inverted); block-size: 1.35em; border-radius: 50%; content: ""; inline-size: 1.35em; inset-block-end: 0.2em; inset-inline-start: 0.2em; position: absolute; transition: 150ms ease; } @media (any-hover: hover) { &:hover { background-color: color-mix(in srgb, var(--switch-color) 80%, var(--color-ink)); } } .switch__input:checked + & { --switch-color: var(--color-link); &::before { transform: translateX(1.2em); } } .switch__input:disabled + & { --switch-color: var(--color-ink-medium); cursor: not-allowed; opacity: 0.5; } } /* Containers that act like (and contain) inputs */ .input--actor { outline-offset: -1px; transition: box-shadow 150ms ease, outline-offset 150ms ease; &:focus-within { --input-border-color: var(--color-selected-dark); outline: var(--focus-ring-size) solid var(--focus-ring-color); } .input { --input-padding: 0; --input-border-radius: 0; --input-background: transparent; --input-border-size: 0; inline-size: 100%; outline: 0; } &:has(.input:is( :autofill, :-webkit-autofill, :-webkit-autofill:hover, :-webkit-autofill:focus)) { -webkit-text-fill-color: var(--color-text); -webkit-box-shadow: 0 0 0px 1000px var(--color-selected) inset; } } .input--hidden { block-size: 0; inline-size: 0; opacity: 0; padding: 0; } .input.boost__input { --input-border-radius: 0; --input-border-size: 0; --input-padding: 0; color: inherit; font-size: inherit; font-weight: inherit; inline-size: min-content; max-inline-size: 3ch; min-inline-size: 1ch; outline: none; @supports (field-sizing: content) { field-sizing: content; max-inline-size: unset; } &:focus { background-color: var(--color-highlight); } &::-webkit-outer-spin-button, &::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } } } ================================================ FILE: app/assets/stylesheets/ios.css ================================================ @layer platform { :root:has([data-platform~=ios]) { &[data-text-size=xsmall] { font-size: 14px; } &[data-text-size=small] { font-size: 15px; } &[data-text-size=medium] { font-size: 16px; } &[data-text-size=large] { font-size: 17px; } &[data-text-size=xlarge] { font-size: 19px; } &[data-text-size=xxlarge] { font-size: 21px; } &[data-text-size=xxxlarge] { font-size: 23px; } } [data-platform~=ios] { .hide-on-ios { display: none; } /* Events /* ------------------------------------------------------------------------ */ .events__column-header { background-color: unset; & > span { display: inline-block; position: relative; &::before { content: ""; display: block; background-color: oklch(from var(--color-canvas) l c h / 0.8); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border-radius: 10em; position: absolute; inset-inline: -1.5ch; inset-block: calc(var(--events-gap) * -0.8); z-index: -1; } } } } } ================================================ FILE: app/assets/stylesheets/knobs.css ================================================ @layer components { .knob { --knob-angle-reserve: 120deg; --knob-option-angle: calc((360deg - var(--knob-angle-reserve)) / (var(--knob-options) - 1)); --knob-option-size: 3ch; --knob-chamfer-size: 1ch; --knob-color: oklch(var(--lch-ink-light)); --knob-color-accent: oklch(var(--lch-blue-medium)); --knob-tick-size: 1ch; --knob-radius: calc(var(--knob-size) / 2); --knob-size: 96px; border: none; display: block; font-weight: 500; padding: var(--knob-option-size) 0 0; position: relative; text-align: center; } .knob__slider { appearance: none; background-color: transparent; block-size: var(--knob-size); inline-size: var(--knob-size); inset: 50% auto auto 50%; opacity: 0; position: absolute; translate: -50% -50%; z-index: 1; &::-moz-range-track { block-size: var(--knob-size); cursor: grab; } &::-webkit-slider-runnable-track { block-size: var(--knob-size); cursor: grab; } &::-moz-range-thumb { background-color: transparent; border: none; border-radius: 0; } &::-webkit-slider-thumb { appearance: none; background-color: transparent; height: 1px; width: 1px; } } .knob__option { block-size: var(--knob-option-size); border-radius: 50%; cursor: pointer; display: grid; inline-size: var(--knob-option-size); inset: 50% auto auto 50%; place-content: center; position: absolute; transform: translate(-50%, -50%) rotate(calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--i)))) translateY(calc(-1 * var(--knob-radius) - 50% - var(--knob-tick-size))); z-index: 1; &:hover, &:has(input:checked) { color: var(--knob-color-accent); } &:has(:focus-visible) { outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: 0; } /* Tick marks */ &:before { background-color: var(--knob-color); block-size: var(--knob-tick-size); content: ""; inline-size: 2px; inset: 100% auto auto 50%; position: absolute; translate: -50% 0; } /* The value text */ span { rotate: calc(((360deg - var(--knob-angle-reserve)) / 2) - (var(--knob-option-angle) * var(--i))); } input { opacity: 0; position: absolute; } } .knob__knob { background: linear-gradient(to top, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%)); block-size: var(--knob-size); border-radius: 50%; box-shadow: 0 0 2px 1px rgba(0,0,0,0.10), 0 2px 4px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.20); inline-size: var(--knob-size); margin-inline: auto; position: relative; &:before, &:after { content: ""; position: absolute; } /* Indent */ &:before { background: linear-gradient(to bottom, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%)); block-size: calc(var(--knob-size) - var(--knob-chamfer-size)); border-radius: 50%; box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.25), inset 0 -1px 0 rgba(255, 255, 255, 0.25); inline-size: calc(var(--knob-size) - var(--knob-chamfer-size)); inset: 50% auto auto 50%; translate: -50% -50%; } /* Indicator */ &:after { background-color: var(--color-ink); block-size: calc(var(--knob-radius) - var(--knob-chamfer-size) / 2); border-radius: 50% 50% 2px 2px; inline-size: 4px; inset: auto auto 50% 50%; rotate: calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--knob-index))); transform-origin: center bottom; transition: rotate 100ms; translate: -50% 0; } } .knob__label { font-weight: bold; margin-block-start: 1ch; text-transform: uppercase; } } ================================================ FILE: app/assets/stylesheets/layout.css ================================================ @layer base { body { display: grid; grid-template-rows: auto 1fr auto 9em; &.public { grid-template-rows: auto 1fr auto; } &.compact-on-touch { @media (any-hover: none) { grid-template-rows: auto 1fr auto; min-height: unset; } } } /* Required for the card column page on mobile, but not needed otherwise */ :where(#global-container) { display: contents; } :where(#header) { position: relative; z-index: var(--z-nav); } :where(#main) { inline-size: 100dvw; margin-inline: auto; max-inline-size: 100dvw; padding-inline: calc(var(--main-padding) + var(--custom-safe-inset-left)) calc(var(--main-padding) + var(--custom-safe-inset-right)); text-align: center; } :where(#footer) { max-inline-size: 100dvw; } :is(#header, #footer) { @media print { display: none; } } } ================================================ FILE: app/assets/stylesheets/lexxy.css ================================================ @import url("lexxy-variables.css") layer(base); @import url("lexxy-content.css") layer(base); @import url("lexxy-editor.css") layer(base); :root { --lexxy-color-ink: var(--color-ink); --lexxy-color-ink-medium: var(--color-ink-dark); --lexxy-color-ink-light: var(--color-ink-medium); --lexxy-color-ink-lighter: var(--color-ink-light); --lexxy-color-ink-lightest: var(--color-ink-lighter); --lexxy-color-ink-inverted: var(--color-ink-inverted); --lexxy-color-canvas: var(--color-canvas); --lexxy-color-accent-dark: var(--color-ink-dark); --lexxy-color-accent-medium: var(--color-ink-medium); --lexxy-color-accent-light: var(--color-ink-light); --lexxy-color-accent-lightest: var(--color-ink-lighter); --lexxy-color-red: oklch(var(--lch-red-medium)); --lexxy-color-green: oklch(var(--lch-green-medium)); --lexxy-color-blue: oklch(var(--lch-blue-medium)); --lexxy-color-purple: oklch(var(--lch-purple-medium)); --lexxy-color-code-token-att: var(--color-code-token__att); --lexxy-color-code-token-comment: var(--color-code-token__comment); --lexxy-color-code-token-function: var(--color-code-token__function); --lexxy-color-code-token-operator: var(--color-code-token__operator); --lexxy-color-code-token-property: var(--color-code-token__property); --lexxy-color-code-token-punctuation: var(--color-code-token__punctuation); --lexxy-color-code-token-selector: var(--color-code-token__selector); --lexxy-color-code-token-variable: var(--color-code-token__variable); --lexxy-color-selected: oklch(var(--lch-blue-light)); --lexxy-color-selected-dark: oklch(var(--lch-blue-medium)); --lexxy-color-table-cell-border: var(--color-ink-ligher); --lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected); --lexxy-color-table-cell-toggle: var(--lexxy-color-selected); --lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%); --lexxy-focus-ring-offset: 2px; } @layer components { /* Editor /* ------------------------------------------------------------------------ */ lexxy-editor { --lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%); --lexxy-editor-padding: 0; --lexxy-toolbar-button-size: 2rem; background-color: transparent; border: none; border-radius: 0; } lexxy-toolbar { border-color: var(--lexxy-border-color); gap: 0; } .lexxy-editor__toolbar-button { background: transparent; &[aria-pressed="true"], [open] > & { background-color: oklch(var(--lch-blue-medium) / 20%); } @media(any-hover: hover) { &:hover:not([aria-pressed="true"]) { background-color: oklch(var(--lch-ink-dark) / 20%); } } } lexxy-link-dropdown { --lexxy-toolbar-spacing: 1.5ch; .lexxy-editor__toolbar-button { border-radius: 99rem; @media(any-hover: hover) { &:hover { filter: brightness(0.9); opacity: 1; } } &[type="submit"], &[type="submit"]:hover { background-color: var(--color-link); } &[type="button"] { border: 1px solid var(--color-ink-light); } } } .lexxy-editor__content { margin-block-start: 0.5lh; } .lexxy-code-language-picker { border-radius: 99rem; } lexxy-table-tools { font-size: var(--text-x-small); } [data-lexical-cursor] { animation: blink 1s step-end infinite; block-size: 1lh; border-inline-start: 1px solid currentColor; line-height: inherit; margin-block: 1em; } .lexxy-prompt-menu { max-inline-size: min(35ch, calc(100% - var(--lexxy-prompt-offset-x))); } /* Content /* ------------------------------------------------------------------------ */ .lexxy-content { --lexxy-content-margin: 0.5lh; color: currentColor; h1, h2, h3, h4, h5, h6 { font-weight: 800; letter-spacing: -0.02ch; line-height: 1.1; overflow-wrap: break-word; text-wrap: balance; } p:has(+ p) { margin: 0; } ol, ul { &:not(.lexxy-prompt-menu) { padding-inline-start: 1ch; } } blockquote { border-inline-start: 0.25em solid var(--color-ink-lighter); padding-block: 0; } code { background: var(--color-canvas); border: 1px solid var(--color-ink-lighter); } .horizontal-divider { padding-block: var(--lexxy-content-margin); hr { margin: 0; } } hr { border: 0; border-block-end: 2px solid currentColor; color: currentColor; inline-size: 20%; margin: calc(var(--lexxy-content-margin) * 2) 0; } table { th, td { font-size: var(--text-small); padding-block: 0.75ch; } tr:not([data-action="delete"]) { th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); } td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); } } } .attachment { margin-inline: auto; } } .attachment { margin-inline: 0; } .attachment-gallery { .attachment { display: inline-block; inline-size: calc(33.333% - 0.8ch); } &.attachment-gallery--2, &.attachment-gallery--4 { .attachment { inline-size: calc(50% - 0.8ch); } } } action-text-attachment[content-type^='application/vnd.actiontext'] { lexxy-node-delete-button { inset-inline-start: -0.25ch; .lexxy-floating-controls__group { background-color: oklch(var(--lch-blue-dark)); border-radius: 50%; } } } } ================================================ FILE: app/assets/stylesheets/lightbox.css ================================================ @layer components { .lightbox { --dialog-duration: 350ms; --lightbox-padding: 3vmin; background-color: transparent; block-size: 100dvh; border: 0; inline-size: 100dvw; inset: 0; margin: auto; max-height: unset; max-width: unset; overflow: hidden; padding: calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) calc(var(--lightbox-padding) + var(--custom-safe-inset-bottom)) calc(var(--lightbox-padding) + var(--custom-safe-inset-left)); text-align: center; &::backdrop { -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); background-color: oklch(var(--lch-black) / 50%); } /* Closed state */ &, &::backdrop { opacity: 0; transition: var(--dialog-duration) allow-discrete; transition-property: display, opacity, overlay; } /* Open state */ &[open], &[open]::backdrop { align-items: center; display: flex; justify-content: center; opacity: 1; @starting-style { opacity: 0; } .lightbox__figure { animation: slide-up var(--dialog-duration); } } } .lightbox__actions { display: flex; gap: 1ch; inset: calc(var(--lightbox-padding) + var(--custom-safe-inset-top)) calc(var(--lightbox-padding) + var(--custom-safe-inset-right)) auto auto; position: absolute; } .lightbox__figure { animation-fill-mode: forwards; animation: slide-down var(--dialog-duration); display: flex; flex-direction: column; gap: var(--lightbox-padding); margin: 0 auto; max-block-size: 100%; img { object-fit: contain; } } .lightbox__caption { color: var(--color-white); &:empty { display: none; } &[tabindex="-1"]:focus-visible { outline: unset; } } .lightbox__image { flex: 1; min-block-size: 0; } /* Prevent body from scrolling when lightbox is open */ html:has(.lightbox[open]) { overflow: clip; } } ================================================ FILE: app/assets/stylesheets/markdown.css ================================================ @layer components { .heading__link { --opacity: 0.5; --size: 0.8em; background: url(link.svg) no-repeat center bottom 0.2em; background-size: var(--size); block-size: 1em; color: var(--color-link); display: inline-flex; font-size: var(--size); inline-size: var(--size); padding: 1em 0 0; opacity: var(--opacity); overflow: hidden; transition: opacity 300ms ease; vertical-align: middle; @media (hover: hover) { --opacity: 0; :is(h1, h2, h3, h4, h5, h6):hover & { --opacity: 0.5; } } html[data-theme="dark"] & { filter: invert(1); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { filter: invert(1); } } } } ================================================ FILE: app/assets/stylesheets/native.css ================================================ @layer native { [data-platform~=native] { --footer-height: 0; -webkit-tap-highlight-color: transparent; .hide-on-native { display: none; } /* Layout /* ------------------------------------------------------------------------ */ &:not(.contained-scrolling) { #main { padding-block-end: var(--custom-safe-inset-bottom); } } /* Header /* ------------------------------------------------------------------------ */ .header { padding-block-start: var(--custom-safe-inset-top); } .header--mobile-actions-stack { .header__title { margin-block-start: 0; } } .header:is( :not(:has(.header__title, .header__actions)), :not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child) ) { block-size: var(--custom-safe-inset-top); padding: unset; * { display: none; } } .header__actions { .btn--back { display: none; } } /* Card columns /* ------------------------------------------------------------------------ */ .board-tools.card { padding-block-start: 0; } /* Card perma /* ------------------------------------------------------------------------ */ .card-perma { margin-block-start: var(--block-space-half); &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { padding-block-end: clamp(0.25rem, 2vw, var(--padding-block)); } .card { background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); box-shadow: unset; } .card__board { border-radius: 0 var(--border-radius) var(--border-radius) 0; } } .card-perma__bg { padding-inline: 0; padding-block-start: 0; background-color: unset; } .card-perma__closure-message { margin-block: var(--block-space); translate: unset; } .card-perma--draft { .card { box-shadow: 0 101vh 0 100vh var(--card-bg-color); } .card-perma__notch--bottom { z-index: 1; } } /* Search /* ------------------------------------------------------------------------ */ .search { overscroll-behavior: auto; } .search__title { text-decoration: none; } } } [data-bridge-components~=form] { [data-controller~=bridge--form] { [data-bridge--form-target~=submit] { display: none; } } } [data-bridge-components~=overflow-menu] { [data-controller~=bridge--overflow-menu] { [data-bridge--overflow-menu-target~=item] { display: none; } } } [data-bridge-components~=buttons] { [data-bridge--buttons-target~=button] { display: none; } } [data-bridge-components~=stamp] { [data-controller~=bridge--stamp] { display: none; } } ================================================ FILE: app/assets/stylesheets/nav.css ================================================ @layer components { /* Trigger /* ------------------------------------------------------------------------ */ .nav__trigger { --input-background: transparent; --input-border-color: transparent; --input-padding: 0.3em 2em 0.3em 0.75em; font-weight: 600; inline-size: auto; margin-block-start: 0.1em; ::-webkit-search-cancel-button { -webkit-appearance: none; } @media (any-hover: hover) { &:hover { --input-background: var(--color-ink-lighter); span { background: var(--color-ink-lighter); } } } span { background: var(--color-ink-lightest); block-size: auto; border-radius: 0.3125em; box-shadow: 0 0 0 1px oklch(var(--lch-ink-darkest) / 0.1), 0 0.1em 0.2em -0.1em oklch(var(--lch-ink-darkest) / 0.05), 0 0.2em 0.4em -0.2em oklch(var(--lch-ink-darkest) / 0.05), 0 0.3em 0.6em -0.3em oklch(var(--lch-ink-darkest) / 0.05) ; display: grid; height: 1.5em; inline-size: 1.5em; padding: 0.325em 0.275em 0.225em 0.275em; place-content: center; width: 1.5em; } svg { height: 100%; margin-inline-start: 0.4125em; margin-inline-end: 0.5375em; max-height: 0.8625em; overflow: visible; width: auto; } } /* Dialog /* ------------------------------------------------------------------------ */ .nav__menu.popup { --panel-border-color: var(--color-selected-dark); --panel-border-radius: 1.4em; --panel-padding: var(--block-space); --panel-size: 45ch; --popup-display: grid; --nav-section-gap: 2px; block-size: auto; box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%), 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); gap: var(--nav-section-gap); grid-template-rows: auto 1fr auto; inset: 0 0 auto 0; margin-inline: auto; max-block-size: calc(100dvh - var(--block-space) - var(--footer-height)); overflow: hidden; padding-block-end: 0; scrollbar-gutter: stable both-edges; transform-origin: top center; translate: 0; z-index: var(--z-nav); @media (max-height: 668px) { max-block-size: calc(100dvh - var(--block-space)); } } .nav__scroll-container { display: flex; flex-direction: column; gap: var(--nav-section-gap); margin-inline: calc(-1 * var(--block-space)); /* space for scrollbar */ overflow: auto; padding-block-end: var(--nav-section-gap); padding-inline: var(--block-space); } .nav__close { @media (any-hover: hover) { display: none !important; } } .nav__section { border-block-start: 1px solid var(--color-ink-lighter); font-size: var(--text-small); &[open] { padding-block-end: 0.4rem; } } .nav__section--secret:not([data-is-filtering]) { display: none; } .nav__header { --actions-width: 2rem; display: grid; grid-template-columns: var(--actions-width) 1fr var(--actions-width); grid-template-areas: "actions-start title actions-end"; justify-items: center; } .nav__header-actions { display: flex; font-size: var(--text-x-small); gap: var(--inline-space); inline-size: var(--actions-width); min-inline-size: fit-content; } .nav__header-actions--end { grid-area: actions-end; justify-content: flex-end; margin-inline-start: auto; } .nav__header-actions--start { grid-area: actions-start; margin-inline-end: auto; } .nav__header-title { color: inherit; grid-area: title; justify-content: center; margin: auto; min-inline-size: 0; text-align: center; } .nav__hotkeys { --gap: 8px; align-items: center; display: flex; flex-wrap: wrap; gap: var(--gap); inline-size: 100%; list-style: none; justify-content: center; margin: var(--block-space) auto calc(var(--block-space-half) / 2); max-inline-size: 100%; /* When all its children are hidden, hide this as well so it doesn't take up space */ &:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) { display: none; } .btn { --btn-border-radius: 0.4em; align-content: end; aspect-ratio: 5/3; background-color: var(--color-ink-lightest); border-radius: 0.5em; container-type: inline-size; flex-basis: calc((100% - var(--gap) * 2) / 3); flex-direction: column; font-size: var(--text-small); line-height: 1; justify-content: center; overflow: hidden; position: relative; row-gap: 0.3lh; text-align: center; kbd { inset: 0.66em 0.33em auto auto; line-height: 1.4; opacity: 0.5; position: absolute; @media (any-hover: none) { /* This is a reasonable way to assert touch devices. any-pointer would seem */ /* to be a better fit but it is incorrectly reported on many devices */ display: none; } } .icon { --icon-size: 2em !important; } span { display: flex; text-wrap: nowrap; } @media (max-width: 639px) { font-size: var(--text-x-small); font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small)); font-weight: 500; } } } .nav__blank-slate { font-size: var(--text-small); margin: 2rem auto; .nav:has(.popup__item:not([hidden])) & { display: none; } } .nav__footer { background-color: var(--color-canvas); border-block-start: 1px solid var(--color-ink-lighter); font-size: var(--text-small); line-height: 1.6; margin-block-start: calc(-1 * var(--nav-section-gap)); padding: 1.5ch; text-align: center; z-index: 1; @media (max-height: 668px) { font-size: var(--text-x-small); } } } ================================================ FILE: app/assets/stylesheets/notifications.css ================================================ @layer components { /* Notifications list /* ------------------------------------------------------------------------ */ .notifications-list { --panel-size: 45ch; .tray__item { position: relative; &[aria-selected] { outline: 0; .card { border-radius: 0.25ch; outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } } .card { @media (prefers-color-scheme: dark) { box-shadow: 0 0 0 1px var(--color-ink-lighter); } } .card__header { column-gap: var(--inline-space-half); } &:has(.card--notification) { .notifications-list__blank-slate { display: none; } } } /* Read items /* ------------------------------------------------------------------------ */ .notifications-list--read { &:not(:has(.card--notification)) { display: none; } .card { box-shadow: 0 0 0 1px var(--color-ink-lighter); } .card__notification-unread-indicator { --btn-background: transparent; --btn-size: 1.8em; margin: 2px; .icon { block-size: 1.7em; color: var(--color-ink); inline-size: 1.7em; opacity: 1; } } } /* Help /* ------------------------------------------------------------------------ */ .notifications-help { h3 { font-size: var(--text-medium); margin: 0; } .icon { --icon-size: 1.2em; vertical-align: text-top; } ol { margin-block: var(--block-space-half) var(--block-space); &:last-of-type { margin-block-end: var(--block-space-half); } } } .notifications-help__explainer { padding: var(--block-space); } .notifications__on-message { display: none; .notifications--on & { display: revert; } } .notifications__off-message { display: revert; .notifications--on & { display: none; } } .notifications__status { --panel-border-radius: 0.5em; --panel-padding: var(--block-space); } } ================================================ FILE: app/assets/stylesheets/pagination.css ================================================ @layer components { .pagination-link { display: block; flex: 1; min-block-size: 1.5rem; pointer-events: none; text-decoration: none; &.pagination-link--active-when-observed { block-size: 0; inline-size: 0; overflow: hidden; visibility: hidden; turbo-frame:has(&):has(~ turbo-frame) & { display: none; } } &[aria-busy="true"] { .spinner { display: block; } } } .day-timeline-pagination-link { block-size: 1px; display: block; inline-size: 1px; overflow: clip; } } ================================================ FILE: app/assets/stylesheets/panels.css ================================================ @layer components { .panel { background-color: var(--panel-bg, var(--color-canvas)); border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-ink-lighter)); border-radius: var(--panel-border-radius, 1em); color: var(--color-ink); inline-size: var(--panel-size, 60ch); max-inline-size: 100%; padding: var(--panel-padding, var(--block-space)); @media (min-width: 640px) { --panel-size: 100%; padding: var(--panel-padding, var(--block-space-double)); } } .panel--wide { --panel-size: 60ch; } .panel--centered { --panel-border-size: 0; --panel-size: 100%; @media (min-width: 640px) { --panel-size: 42ch; } #main:has(&) { display: grid; justify-content: center; margin: auto; } } } ================================================ FILE: app/assets/stylesheets/performance-notice.css ================================================ .performance-notice { background: oklch(var(--lch-yellow-lightest)); border-radius: 1ch; border: 1px solid oklch(var(--lch-yellow-light)); font-size: var(--text-small); margin-block-end: 2ch; margin-inline: auto; max-inline-size: 60ch; padding-inline: 2ch; padding: 1ch; } ================================================ FILE: app/assets/stylesheets/pins.css ================================================ @layer components { .pins-list { --panel-size: 45ch; } } ================================================ FILE: app/assets/stylesheets/popup.css ================================================ @layer components { .popup { --btn-background: transparent; --panel-border-radius: 0.5em; --panel-padding: var(--block-space); --panel-size: auto; --popup-icon-size: 24px; --popup-item-padding-inline: 0.4rem; --popup-display: flex; inset: 0 auto auto 50%; max-block-size: 70dvh; max-inline-size: min(55ch, calc(100vw - (var(--panel-padding)))); min-inline-size: min(25ch, calc(100vw - (var(--panel-padding)))); overflow: auto; position: absolute; translate: -50%; z-index: var(--z-popup); &[open] { display: var(--popup-display); } /* The .pop-up--align- classes are used for initial alignment. * The orient JS helper layers on the .orient- to avoid running * off the edge of the screen and will override .popup--align */ &:where(.popup--align-left), &.orient-left { inset-inline: auto 0; translate: var(--orient-offset, 0px); } &:where(.popup--align-right), &.orient-right { inset-inline: 0 auto; translate: var(--orient-offset, 0px); } form { display: contents; } } .popup__footer { border-block-start: 1px solid var(--color-ink-lightest); color: var(--card-color); margin-block-start: var(--popup-item-padding-inline); padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0; text-align: center; text-transform: initial; } .popup__title { font-weight: 800; white-space: nowrap; &[tabindex="-1"]:focus-visible { outline: unset; } } /* Hide lists when all the items within are hidden */ .popup__section { &:not(:has(.popup__list)), &:not(:has(.popup__list > *)), &:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) { display: none; } } .popup__section-title { background: var(--color-canvas); font-size: var(--text-small); font-weight: 600; inset-block-start: 0; list-style: none; padding: 0.75ch var(--inline-space-half); position: sticky; text-transform: uppercase; z-index: 1; &:is(summary) { align-items: center; cursor: pointer; display: flex; gap: 0.5ch; } &::-webkit-details-marker { display: none; } .icon--caret-down { font-size: 1ch; margin-inline-start: -0.5ch; transition: rotate 150ms ease-out; } .popup__section:not([open]) & { .icon--caret-down { rotate: -90deg; } } } .popup__list { display: flex; flex-direction: column; inline-size: 100%; list-style: none; margin: 0; max-inline-size: 100%; padding: 0; row-gap: 1px; details & { margin-inline-start: 0.75ch; } } .popup__item { align-items: center; background: transparent; border-radius: 0.3em; display: flex; inline-size: 100%; max-inline-size: 100%; @media (any-hover: hover) { &:hover { background: var(--color-ink-lightest); } } &:has(.popup__btn[disabled]) { pointer-events: none; } .checked { display: none; } &[aria-checked="true"] .checked { display: block; } &[aria-selected] { background-color: var(--color-selected); @media (any-hover: hover) { &:hover { background-color: var(--color-selected); } } } } /* The actionable thing with padding within popup__item */ .popup__btn { --btn-border-radius: 0.3em; --btn-border-size: 0; flex: 1 1 auto; font-weight: 500; justify-content: start; inline-size: 100%; min-inline-size: 0; max-inline-size: 100%; padding: var(--inline-space-half) var(--popup-item-padding-inline); text-align: start; &:focus-visible { z-index: 1; } } .popup__icon { --icon-size: 1em; inline-size: var(--popup-icon-size); margin-inline-start: var(--popup-item-padding-inline); } .popup__radio { --icon-size: var(--text-x-small); block-size: var(--popup-icon-size); inline-size: var(--popup-icon-size); margin-inline-start: var(--popup-item-padding-inline); flex-shrink: 0; &:hover { --btn-border-color: var(--color-ink); } } /* Animated /* -------------------------------------------------------------------------- */ .popup--animated { opacity: 0; transform: scale(0.2); transform-origin: top left; /* Works as `top center` because popups have `translate: -50%` */ transition: var(--dialog-duration) allow-discrete; transition-property: display, opacity, overlay, transform; &::backdrop { background-color: var(--color-always-black); opacity: 0; transform: scale(1); transition: var(--dialog-duration) allow-discrete; transition-property: display, opacity, overlay; } &[open] { opacity: 1; transform: scale(1); &::backdrop { opacity: 0.5; } } @starting-style { &[open] { opacity: 0; transform: scale(0.2); } &[open]::backdrop { opacity: 0; } } } } ================================================ FILE: app/assets/stylesheets/print.css ================================================ @media print { /* Global /* ------------------------------------------------------------------------ */ :root { --color-ink: black; --color-canvas: white; --border-dark: 1px solid var(--color-ink); --border-light: 1px solid color-mix(in oklch, var(--color-ink), transparent 75%); --font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Hiragino Sans GB", "Hiragino Sans", "Apple SD Gothic Neo", "Microsoft YaHei", "Meiryo", "Malgun Gothic"; } @page { margin: 0.5in; } html { font-size: 10pt; } main { inline-size: unset; margin: 0; orphans: 3; padding: 0; widows: 2; } /* Browsers usually disable backgrounds on print, so this ensures icons (which use BG masks) will show up. */ .icon, .knob, .switch { -webkit-print-color-adjust: exact; color-adjust: exact; print-color-adjust: exact; } .nav__menu, .nav__trigger, .header__actions { display: none; } .header { padding: 0; } .header__title { margin-block-end: 1ch; } /* Cards /* -------------------------------------------------------------------------- */ .card { --card-color: var(--color-ink) !important; background: var(--color-canvas); border: var(--border-light); box-shadow: none; break-inside: avoid; color: var(--color-ink); } .card { .card__board { background: var(--color-canvas); border-block-end: var(--border-light); border-inline-end: var(--border-light); color: var(--color-ink); } } .card__title { font-weight: bold; } /* Events /* ------------------------------------------------------------------------ */ .events__columns { border-inline: none; } .events__column-header { background: none; margin: 0; padding-block: 1ch; } .events__time-block { padding: 0 1ch; .events__column:first-child & { padding-inline-start: 0; } .events__column:last-child & { padding-inline-end: 0; } } .events__none { padding-block: 2ch; } .event { --card-color: var(--color-ink) !important; background: var(--color-canvas); border: var(--border-light); box-shadow: none; break-inside: avoid; color: var(--color-ink); .avatar { --avatar-size: 2lh; } } /* Boards /* ------------------------------------------------------------------------ */ .filters, .card--new, .cards__decoration, .card-columns:before, .cards--maybe:before { display: none; } .card-columns { border-block-start: var(--border-light); margin-block-end: 1ch; min-block-size: unset; } .cards--on-deck, .cards--doing { padding-inline: 0; } .cards--maybe { background: none; margin: 0; padding-inline: 1ch; .card__header { margin-inline: calc(-1 * var(--card-padding-inline)); } .card__body { padding-block-start: calc(var(--card-padding-block) / 2); } } /* Card perma /* ------------------------------------------------------------------------ */ .card-perma__notch, .card-perma__actions, .comment--new, .comments__subscribers, .card__meta-avatars--assignees > div > div:last-child, .steps > .step:last-child, .card__board-name .icon, div:has(> .card__tag-picker-button), .delete-card, .header--card .header__title { display: none; } .card-perma { display: block; inline-size: 100%; margin: 0 0 1lh; .card { aspect-ratio: unset; } .card__title { font-size: var(--text-x-large); } } .card-perma__bg { background: transparent; padding: 0; } .comments { --row-gap: 0; --comment-padding-inline: 1.4lh; padding-inline: 0; } .comment { --comment-max: none; border-block-end: var(--border-light); } .comment__content { background: none; } .comment__avatar { --btn-size: 2lh; margin-inline-start: 0; } .comment__author h3 { margin-inline: 0; } .comment__edit { display: none; } .comment__body { text-align: start; } .reactions { margin-block-start: 0; } /* Settings /* ------------------------------------------------------------------------ */ .settings__user-filter .input { display: none; } .settings__panel { border: none; border-radius: 0; box-shadow: none; padding: 0; text-align: left; h2 { &:before, &:after { display: none; } } } .settings__panel--users { form:has(.btn--negative) { display: none; } .btn { background-color: transparent; border: none; color: var(--color-ink); opacity: 1; } .btn:not(:has(input:checked)) { opacity: 0; } } .settings__panel--entropy { display: none; } .settings__user-filter { background: none; margin: 0; padding: 0; /* Hide the "Everyone" switch */ > li:first-child { display: none; } } } ================================================ FILE: app/assets/stylesheets/pwa.css ================================================ /* PWA install */ .pwa__instructions { @media (display-mode: standalone) { display: none; } } .pwa__installer { display: none; .pwa--can-install & { display: block; } } ================================================ FILE: app/assets/stylesheets/qr-codes.css ================================================ @layer components { .qr-code { aspect-ratio: 1; border-radius: 1ch; inline-size: clamp(20ch, 50dvh, 70ch); margin-block: var(--block-space); } } ================================================ FILE: app/assets/stylesheets/reactions.css ================================================ @layer components { .reactions { --btn-icon-size: 1.3em; --column-gap: 0.4ch; --reaction-border-color: var(--color-ink-lighter); --row-gap: 0; align-items: center; display: flex; flex-wrap: wrap; gap: var(--inline-space-half); inline-size: 100%; z-index: 3; &:has([open]) { z-index: var(--z-popup); } &:not(:has(.reaction)) { inline-size: auto; .reactions__list { display: none; } .reactions__trigger { --btn-border-color: var(--color-ink-lightest); background-color: var(--color-ink-lightest); } } } .reactions__trigger { --btn-size: var(--reaction-size); --btn-border-color: var(--reaction-border-color); img { block-size: 65%; inline-size: 65%; } } .reactions__list { display: inline-flex; flex-wrap: wrap; gap: var(--inline-space-half); &:not(:has(.reaction)) { display: none; } } /* Single reaction /* -------------------------------------------------------------------------- */ .reaction { --btn-size: 100%; --reaction-hover-brightness: 0.9; align-items: center; background-color: var(--color-canvas); block-size: var(--reaction-size); border: 1px solid var(--reaction-border-color); border-radius: 4rem; display: inline-flex; gap: 0.25ch; max-inline-size: 100%; opacity: 1; padding: 0.1em 0.36em 0.1em 0.12em; position: relative; transition: opacity 100ms ease-in-out, transform 150ms ease-in-out; &:has(span.txt-small.txt-medium) { /* emoji only reactions */ padding: 0.1em 0.12em; } .btn { font-size: 0.6em; inline-size: auto; } @media (any-hover: none) { padding-inline-end: 0.12em; } } .reaction--deleteable { cursor: pointer; @media (any-hover: hover) { &:not(.expanded):hover { filter: brightness(var(--reaction-hover-brightness)); html[data-theme="dark"] & { --reaction-hover-brightness: 1.25; } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { --reaction-hover-brightness: 1.25; } } } } } /* Make the avatar and delete buttons fit nicely within the reaction */ .reaction__avatar, .reaction__avatar .avatar, .reaction__form-label, .reaction form { block-size: 100%; } .reaction__delete { display: none; .expanded & { display: grid; } } .reaction__form { transition: none; &.expanded { animation: react 300ms both; transform: translate3d(0, 0, 0); transform-origin: left center; } &:has(.input:focus) { outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: -1px; } .reaction__form-label:focus { outline: none; } } .reaction__input { --input-background: transparent; --input-border-size: 0; --input-padding: 0; box-shadow: none; display: inherit; max-inline-size: 16ch; min-inline-size: 2em; outline: 0; } .reaction--deleting { animation: scale-fade-out 0.2s both; } .reaction__menu-btn, .reaction__submit-btn, .reaction__cancel-btn { --btn-size: 1.25rem; --icon-size: var(--btn-size); @media (any-hover: none) { --btn-size: 2rem; --icon-size: 90%; } } .reaction__submit-btn { color: oklch(var(--lch-green-dark)); } .reaction__cancel-btn { color: oklch(var(--lch-red-dark)); } /* Menu /* ------------------------------------------------------------------------ */ .reaction__menu { position: relative; } .reaction__popup { --panel-border-radius: 1em; --panel-padding: var(--block-space-half) var(--inline-space); --offset: calc(-1 * var(--panel-padding)); inset: var(--offset) auto auto var(--offset); min-inline-size: auto; transform: none; } .reaction__emoji-list { display: grid; gap: var(--inline-space-half); grid-template-columns: repeat(10, 1fr); @media (max-width: 639px) { grid-template-columns: repeat(6, 1fr); } .btn { --btn-size: calc(1.3rem * 1.3); font-size: 1.3rem; position: relative; /* Make sure the focus ring sits on top of adjacent buttons */ &:hover, &:focus-visible { filter: none; z-index: 1; } &:hover { scale: 1.3; } @media (any-hover: none) { --btn-size: calc(1.6rem * 1.3); font-size: 1.6rem; } } } } ================================================ FILE: app/assets/stylesheets/reset.css ================================================ @layer reset { /* * Modern CSS Reset * @link https://github.com/hankchizljaw/modern-css-reset */ /* Box sizing rules */ *, *::before, *::after { box-sizing: border-box; } /* Remove default margin */ body, h1, h2, h3, h4, h5, h6 { margin: 0; } p, li, h1, h2, h3, h4 { /* Help prevent overflow of long words/names/URLs */ word-break: break-word; /* Optional, not supported for all languages */ /* hyphens: auto; */ } html, body { overflow-x: clip; } html { /* scroll-behavior: smooth; */ } /* Set core body defaults */ body { min-height: 100dvh; font-family: sans-serif; font-size: 100%; line-height: 1.5; text-rendering: optimizeSpeed; } /* Make images easier to work with */ img { display: block; max-inline-size: 100%; } /* Inherit fonts for inputs and buttons */ input, button, textarea, select { font: inherit; } button { cursor: pointer; } summary { &::-webkit-details-marker { display: none; } &::marker { content: ""; } } /* Remove all animations and transitions for people that prefer not to see them */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } html { scroll-behavior: initial; } } dialog { border: 0; padding: 0; &:where(:focus-visible):focus, &:where(:focus-visible):active { outline: 0; } } } ================================================ FILE: app/assets/stylesheets/search.css ================================================ summary { &::-webkit-details-marker { display: none !important; } &::marker { display: none !important; } &::marker { content: ""; } } @layer components { .search { --gap: 4vmin; --width: 80ch; display: flex; flex-direction: column; gap: var(--gap); margin-inline: auto; max-block-size: 100%; overflow-y: auto; overscroll-behavior: contain; padding: var(--block-space); } /* Form /* ------------------------------------------------------------------------ */ .search__input { --clear-icon-size: 1em; max-inline-size: 50ch; position: relative; &::-webkit-search-cancel-button { display: none; } .bar__input & { --focus-ring-size: 0; --input-border-color: var(--color-ink-light); --input-border-radius: 0; --input-padding: 0.1em; border-width: 0 0 1px; } } .search__reset { --btn-background: var(--color-terminal-bg); --btn-size: 1.5lh; } /* Content /* ------------------------------------------------------------------------ */ .search__list { display: grid; gap: var(--block-space); list-style: none; margin: 0 auto; max-inline-size: min(80ch, 100%); padding: 0; text-align: start; } .search__blank-slate { margin-block: 3em; margin-inline: auto; inline-size: fit-content; } .search__excerpt { color: var(--color-ink); font-size: var(--text-small); } .search__excerpt--comment { --avatar-size: var(--comment-avatar-size); --comment-avatar-size: 32px; --padding: 1ch; align-items: center; background-color: var(--color-ink-lightest); border-radius: 1ch; display: flex; gap: 1ch; margin-inline-start: calc(var(--comment-avatar-size) / 2); padding-block: 0.5ch; .avatar { margin-inline-start: calc(-0.5 * var(--comment-avatar-size)); } } .search__result { color: var(--color-link); &:not(&:hover) { box-shadow: 0 0 0 1px var(--color-ink-lighter); } } .search__title { text-decoration: underline; } /* Perma /* ------------------------------------------------------------------------ */ .search-perma { .search__form > label, .search__form:has(.search__input:placeholder-shown) .search__reset { display: none; } .search__input { max-inline-size: min(80ch, 100%); } .search { padding-inline: 0; } } } ================================================ FILE: app/assets/stylesheets/separators.css ================================================ @layer components { .separator { block-size: 100%; border-block: 0; border-inline-end: 0; border-inline-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, currentColor); display: inline-flex; inline-size: 0; } .separator--horizontal { block-size: 0; border-block-end: 0; border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, currentColor); border-inline: 0; display: flex; } } ================================================ FILE: app/assets/stylesheets/settings.css ================================================ @layer components { .settings { --settings-spacer: var(--block-space); --settings-item-padding-inline: 0.5ch; align-items: start; display: flex; gap: calc(var(--settings-spacer) * 2); flex-direction: column; justify-content: center; margin: 0 auto; max-inline-size: min(100ch, 100%); @media (min-width: 960px) { flex-direction: row; .settings__panel { max-inline-size: 50%; } } } /* Sections & Panels /* -------------------------------------------------------------------------- */ .settings__panel { --panel-size: 100%; --panel-padding: calc(var(--settings-spacer) / 1); display: flex; flex-direction: column; gap: var(--panel-padding); min-block-size: 100%; min-inline-size: 0; @media (min-width: 640px) { --panel-padding: calc(var(--settings-spacer) * 2); } } .settings__panel--users { @media (min-width: 640px) { max-height: 80dvh; } @media (min-width: 960px) { max-height: calc(100dvh - 12rem); } } .settings__section { h2 { font-size: var(--text-large); } > * + * { margin-block-start: calc(var(--panel-padding) / 2); } &:is(:first-child):has(h2) { margin-top: -0.33lh; /* Align h2 letters caps with panel padding */ } } .settings__section:has(.settings__scrollable-list) { @media (min-width: 640px) { display: flex; flex: 1; flex-direction: column; min-height: 0; } } .settings__scrollable-list { flex: 1 1 auto; list-style: none; margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline)); padding: 0; overflow: auto; li { border-radius: 0.5em; /* Add padding if it's not already on a link within */ &:not(:has(a:first-child)) { padding-inline-end: var(--settings-item-padding-inline); } &:not(:has(a:last-child)) { padding-inline-end: var(--settings-item-padding-inline); } } a { padding: calc(var(--settings-spacer) / 4) var(--settings-item-padding-inline); @media(any-hover: hover) { &:hover { text-decoration: underline; } } } /* Only add a BG color when you can actually navigate */ .settings__user-filter:focus-within & { [aria-selected] { background: var(--color-selected); } } } /* Users /* ------------------------------------------------------------------------ */ .settings__user-filter { --btn-size: 3.5ch; --avatar-size: var(--btn-size); display: flex; flex-direction: column; gap: calc(var(--settings-spacer) / 2); min-height: 0; } .settings__user-filter--bg { background-color: var(--color-ink-lightest); border-radius: 0.5em; margin-block: 0; padding: var(--settings-spacer); } } ================================================ FILE: app/assets/stylesheets/spinners.css ================================================ @layer components { .spinner { position: relative; &::before { --mask: no-repeat radial-gradient(#000 68%, #0000 71%); --dot-size: 1.25em; -webkit-mask: var(--mask), var(--mask), var(--mask); -webkit-mask-size: 28% 45%; animation: submitting 1.3s infinite linear; aspect-ratio: 8/5; background: currentColor; content: ""; inline-size: var(--dot-size); inset: 50% 0.25em; margin-block: calc((var(--dot-size) / 3) * -1); margin-inline: calc((var(--dot-size) / 2) * -1); position: absolute; } } } ================================================ FILE: app/assets/stylesheets/steps.css ================================================ @layer components { .step { display: grid; grid-template-columns: 1em auto auto; gap: calc(var(--inline-space) * 2/3); inline-size: auto; } .step__checkbox { --hover-color: var(--card-color); appearance: none; background-color: var(--color-canvas); block-size: 1.1em; border: 1px solid currentColor; border-radius: 0.15em; color: currentColor; display: grid; font: inherit; inline-size: 1.1em; margin: 0; place-content: center; transform: translateY(0.1em); &::before { background-color: CanvasText; block-size: 0.65em; box-shadow: inset 1em 1em currentColor; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); content: ""; inline-size: 0.65em; transform: scale(0); transform-origin: center; transition: 150ms transform ease-in-out; } &:checked::before { transform: scale(1) rotate(10deg); } &:where([disabled]):not(:hover):not(:active) { filter: none; opacity: 0.5; } } .step__content { --input-border-radius: 0; --input-border-size: 0; --input-padding: 0; border-bottom: 1px solid transparent; color: currentColor; font-weight: 500; margin-block-end: calc(var(--block-space) * 1/3); &:is(a, input[type=text]) { --hover-size: 0; } .step:has(:checked) & { opacity: 0.7; text-decoration: line-through; } &::placeholder { color: var(--card-color); } &:is(input) { max-inline-size: 70ch; min-inline-size: 30ch; @supports (field-sizing: content) { field-sizing: content; max-inline-size: 100%; min-inline-size: 15ch; } } } .step__content--edit { border-bottom-color: currentColor; } .steps { contain: inline-size; display: grid; list-style: none; margin: 0; max-inline-size: 100%; padding: 0; } .steps__icon { --icon-size: 0.875em; background-color: var(--card-color); block-size: 1.3em; border-radius: 50%; color: var(--color-ink-inverted); display: grid; inline-size: 1.3em; place-content: center; } } ================================================ FILE: app/assets/stylesheets/syntax.css ================================================ @layer components { .highlight { /* Named color values */ --keyword: lch(50.16 68.78 25.97); --entity: lch(39.03 73.26 304.21); --constant: lch(39.68 63.13 279.47); --string: lch(19.22 34.92 275.47); --variable: lch(57.9 81.69 53.33); --comment: lch(47.93 7 254.8); --entity-tag: lch(39.64 68.17 142.85); --markup-heading: lch(39.68 63.13 279.47); --markup-list: lch(40.44 43.36 84.69); --markup-inserted: lch(39.64 68.17 142.85); --markup-deleted: lch(39.64 68.17 31.45); /* Redefine named color values for dark mode */ html[data-theme="dark"] { --keyword: lch(67.63 58.99 30.64); --entity: lch(75.13 46.73 306.74); --constant: lch(74.9 39.71 255.53); --string: lch(74.9 39.71 255.53); --variable: lch(76.17 61.1 61.97); --comment: lch(60.83 6.66 254.46); --entity-tag: lch(83.65 59.31 141.61); --markup-heading: lch(47.93 71.67 280.72); --markup-list: lch(83.84 57.9 85.03); --markup-inserted: lch(83.65 59.31 141.61); --markup-deleted: lch(73.8% 65 29.18); } @media (prefers-color-scheme: dark) { html:not([data-theme]) { --keyword: lch(67.63 58.99 30.64); --entity: lch(75.13 46.73 306.74); --constant: lch(74.9 39.71 255.53); --string: lch(74.9 39.71 255.53); --variable: lch(76.17 61.1 61.97); --comment: lch(60.83 6.66 254.46); --entity-tag: lch(83.65 59.31 141.61); --markup-heading: lch(47.93 71.67 280.72); --markup-list: lch(83.84 57.9 85.03); --markup-inserted: lch(83.65 59.31 141.61); --markup-deleted: lch(73.8% 65 29.18); } } color: var(--color-ink); .w { color: var(--color-ink); } .k, .kd, .kn, .kp, .kr, .kt, .kv { color: var(--keyword); } .gr { color: var(--color-ink-lightest); } .gd { color: var(--markup-deleted); background-color: light-dark(lch(39.64 68.17 31.45 / 0.15), lch(39.64 68.17 31.45 / 0.2)); } .nb, .nc, .no, .nn { color: var(--variable); } .sr, .na, .nt { color: var(--entity-tag); } .gi { color: var(--markup-inserted); background-color: light-dark(lch(49.14 52.75 142.85 / 0.15), lch(83.65 59.31 141.61 / 0.15)); } .kc, .l, .ld, .m, .mb, .mf, .mh, .mi, .il, .mo, .mx, .sb, .bp, .ne, .nl, .py, .nv, .vc, .vg, .vi, .vm, .o, .ow { color: var(--constant); } .gh { color: var(--constant); font-weight: bold; } .gu { color: var(--constant); font-weight: bold; } .s, .sa, .sc, .dl, .sd, .s2, .se, .sh, .sx, .s1, .ss { color: var(--string); } .nd, .nf, .fm { color: var(--entity); } .err { color: var(--color-ink-inverted); background-color: var(--markup-deleted); } .c, .ch, .cd, .cm, .cp, .cpf, .c1, .cs, .gl, .gt { color: var(--comment); } .ni, .si { color: var(--storage-modifier-import); } .ge { color: var(--storage-modifier-import); font-style: italic; } .gs { color: var(--storage-modifier-import); font-weight: bold; } } } ================================================ FILE: app/assets/stylesheets/theme-switcher.css ================================================ @layer components { .theme-switcher { @media (max-width: 479px) { --row-gap: 1ch; flex-direction: column; } } .theme-switcher__btn { --btn-background: var(--color-ink-lightest); --btn-border-radius: 0.4em; --btn-border-size: 0; --btn-gap: 0.1lh; --btn-padding: 1em; --icon-size: 2em; column-gap: var(--inline-space); flex: 1; flex-direction: column; position: relative; white-space: nowrap; &:has(input:checked) { --btn-background: var(--color-selected); --btn-color: var(--color-ink); } @media (max-width: 479px) { flex-direction: row; } } } ================================================ FILE: app/assets/stylesheets/toggles.css ================================================ @layer components { .toggler--toggled { .toggler__visible-when-off { display: none; } .toggler__visible-when-on { display: unset; } } .toggler__visible-when-on { display: none; } } ================================================ FILE: app/assets/stylesheets/tooltips.css ================================================ @layer components { [data-controller~="tooltip"] { --tooltip-delay: 750ms; --tooltip-duration: 150ms; .for-screen-reader { background: var(--color-ink); border-radius: 0.5ch; color: var(--color-canvas); font-size: var(--text-x-small); font-weight: normal; inset: -1ch auto auto 50%; max-inline-size: min(50ch, calc(100vw - (var(--inline-space) * 2))); opacity: 0; padding: 0.25ch 1ch; transition: var(--tooltip-duration) ease-out allow-discrete; transition-property: opacity; translate: -50% -100%; text-overflow: ellipsis; &.orient-right { inset-inline: 0 auto; translate: var(--orient-offset, 0px) -100%; } &.orient-left { inset-inline: auto 0; translate: var(--orient-offset, 0px) -100%; } } @media(any-hover: hover) { &:hover .for-screen-reader { block-size: auto !important; clip-path: none !important; inline-size: auto !important; opacity: 1; transform: translate3d(0, 0, 0); /* Fixes Safari overflow rendering bug */ transition-delay: var(--tooltip-delay); translate: -50% -100%; z-index: var(--z-tooltip); &.orient-left, &.orient-right { translate: 0 -100%; } } } } } ================================================ FILE: app/assets/stylesheets/trays.css ================================================ @layer components { /* Container /* ------------------------------------------------------------------------ */ .tray { --tray-duration: 350ms; --tray-margin: 0.5rem; --tray-radius: 0.25rem; --tray-item-height: 76px; /* FIXME: Magic number */ align-items: end; block-size: var(--footer-height); display: grid; inset-block: auto env(safe-area-inset-bottom); inline-size: var(--tray-size); position: fixed; transition: inset var(--tray-duration) var(--ease-out-overshoot-subtle); z-index: var(--z-tray); /* Make the dialog, expander, and actions inhabit the same space */ > * { grid-column-start: 1; grid-row-start: 1; } @media (max-width: 799px) { &:has(.tray__dialog[open]) { background-color: var(--color-terminal-bg); inline-size: calc(100% - var(--tray-margin) * 2); inset-inline-start: var(--tray-margin); z-index: calc(var(--z-tray) + 2); } } } /* Dialog /* ------------------------------------------------------------------------ */ .tray__dialog { background-color: transparent; display: flex; flex-direction: column-reverse; inline-size: auto; inset: auto 0 0 0; position: absolute; transition: var(--tray-duration) var(--ease-out-overshoot-subtle); transition-property: background-color, box-shadow, inset-block-end; &[open] { border-radius: var(--tray-radius); box-shadow: 0 0 0 1px var(--color-ink-lighter), 0 0 16px oklch(var(--lch-black) / 33%); inset-block-end: calc(var(--footer-height) - 0.75ch); } &:not([open]) { block-size: var(--footer-height); pointer-events: none; /* On desktop, when there aren't items, tweak the hat so it doesn't look like it's coming from the bottom of the viewport */ @media (min-width: 800px) { &:not(:has(.tray__item--notification)) { .tray__item--hat { margin-block-end: 0; opacity: 0; } } } } } /* Expander /* ------------------------------------------------------------------------ */ .tray__toggle { align-self: stretch; background: none; border: 0; display: block; padding: 0; transition: opacity 100ms ease-out; .icon { color: var(--color-ink); display: none; } .tray__toggle-text { display: contents; } @media (max-width: 799px) { /* When collapsed on mobile, make it small */ .tray__dialog:not([open]) ~ & { inline-size: var(--footer-height); .tray__toggle-btn { border: 0; } .icon { display: block; } .tray__toggle-text { display: none; } } /* Show a red dot if there are items to show */ .tray__dialog:not([open]):has(.tray__item--notification) ~ &:after { background: oklch(var(--lch-red-medium)); block-size: 1ch; border-radius: 50%; content: ""; inline-size: 1ch; inset: 25% 25% auto auto; position: absolute; } } /* On desktop… */ @media (min-width: 800px) { .tray__dialog:not([open]):has(.tray__item:not(.tray__item--hat)) ~ & { block-size: var(--tray-item-height); translate: 0 -1.85rem; } /* Hide the UI when collapsed, but only if there are items */ .tray__dialog:not([open]):has(.tray__item--notification) ~ & { opacity: 0; } } } .tray__toggle-btn { --btn-background: transparent; --btn-border-size: 0; --btn-color: var(--color-ink); inline-size: 100%; opacity: 0.66; } /* Item /* ------------------------------------------------------------------------ */ .tray__item { --tray-item-delay: calc((var(--tray-item-index) - 1) * 20ms); --tray-item-index: 1; --tray-item-margin: calc(-1 * var(--tray-item-height) + var(--tray-item-offset)); --tray-item-offset: var(--block-space-half); /* The amount they overlap */ --tray-item-scale: calc(1 - (var(--tray-item-index) - 1) / 30); --tray-item-z: calc(6 - var(--tray-item-index)); font-size: 10px; margin-block-end: var(--tray-item-margin); position: relative; .tray__dialog & { transition: var(--tray-duration) var(--ease-out-overshoot-subtle); transition-delay: var(--tray-item-delay); transition-property: margin, opacity, scale; &:not(.tray__item--hat) { z-index: var(--tray-item-z); } &:has(*:focus-visible) { z-index: calc(var(--tray-item-z) + 1); } &:first-child { --tray-item-margin: var(--tray-margin); } &:not(:first-child) { scale: var(--tray-item-scale); } &:nth-child(1) { --tray-item-index: 1; } &:nth-child(2) { --tray-item-index: 2; } &:nth-child(3) { --tray-item-index: 3; } &:nth-child(4) { --tray-item-index: 4; } &:nth-child(5) { --tray-item-index: 5; } &:nth-child(6) { --tray-item-index: 6; } &:nth-child(7) { --tray-item-index: 7; } &:nth-child(8) { --tray-item-index: 8; } &:nth-child(9) { --tray-item-index: 9; } &:nth-child(10) { --tray-item-index: 10; } } .tray__dialog[open] & { --tray-item-margin: 0; --tray-item-scale: 1; } .tray__dialog:not([open]) & { @media (max-width: 799px) { opacity: 0; } } .bubble { display: none; } } .tray__item--hat { --tray-hat-bg: var(--color-canvas); --tray-item-scale: 1; background-color: var(--tray-hat-bg); border-block-end: 1px solid var(--color-ink-lighter); border-radius: var(--tray-radius) var(--tray-radius) 0 0; block-size: var(--tray-item-height); display: flex; opacity: 0; padding: 0.5ch; .btn { --btn-background: var(--tray-hat-bg); --btn-border-radius: 0.5ch; --btn-padding: 1.25ch 0.5ch 1ch; block-size: 100%; display: flex; flex-direction: column; font-weight: normal; gap: 0.25ch; inline-size: 100%; &:focus-visible { z-index: 1; } @media (max-width: 1060px) { > span:not(.icon) { font-size: 12px; } } .tray__dialog:not([open]) & { pointer-events: none; } } > *:is(:first-child, :last-child) { inline-size: 128px; } .tray__dialog[open] & { opacity: 1; } .tray__new-notifications { display: none; position: relative; } .tray__dialog:has(.tray__item:nth-child(1n + 7)) & { .tray__old-notifications { display: none; } .tray__new-notifications { display: flex; } .btn:has(.tray__new-notifications) { /* Red dot */ &:after { background-color: oklch(var(--lch-red-medium)); block-size: 1ch; border-radius: 50%; box-shadow: 0 0 0 1px var(--tray-hat-bg); content: ""; inline-size: 1ch; inset: 25% auto auto 50%; position: absolute; translate: 25% -75%; } } } } /* Tray cards /* ------------------------------------------------------------------------ */ .tray__item { .card { --card-padding-block: 1.5ch; --card-padding-inline: 1.5ch; --text-xx-large: 2em; block-size: var(--tray-item-height); view-transition-name: unset !important; [open] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); border: 0; border-radius: 0; } html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } @media (prefers-color-scheme: dark) { html:not([data-theme]) & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } } } .card__background { display: none; } .card__body { margin-block-start: 0.2em; padding-block-end: 0; } .card__board { padding-block: 0.25ch; } .card__title { --lines: 1; font-size: var(--text-small); font-weight: bold; min-block-size: 0; } } /* Pin-specific styles /* ------------------------------------------------------------------------ */ .tray--pins { inset-inline: var(--tray-margin) auto; view-transition-name: tray-pins; #footer:has(.bar__placeholder[hidden]) & { inset-inline-start: -100%; } /* Disable the expander if there aren't items to show */ .tray__dialog:not(:has(.tray__item)) ~ .tray__toggle { opacity: 0.5; &, .tray__toggle-btn { pointer-events: none; } } /* Add a border on mobile */ @media (max-width: 799px) { .tray__dialog:not([open]) ~ .tray__toggle { border-inline-end: 1px dashed var(--color-ink-light); translate: calc(-1 * var(--tray-margin)) 0; } } } .tray__item--pin { --tray-item-z: calc(10 - var(--tray-item-index)); position: relative; [open] &[aria-selected] { outline: 0; z-index: calc(var(--tray-item-z) + 2); .card__link { border-radius: 0.25ch; outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); z-index: 1; } } /* Show 6 max items on smallest devices */ @media (max-height: 578px) { &:nth-child(1n + 7) { display: none; } } /* 7 max */ @media (min-height: 578px) and (max-height: 656px) { &:nth-child(1n + 8) { display: none; } } /* 8 max */ @media (min-height: 656px) and (max-height: 734px) { &:nth-child(1n + 9) { display: none; } } /* 9 max */ @media (min-height: 734px) and (max-height: 812px) { &:nth-child(1n + 10) { display: none; } } /* 10 max on larger screens */ @media (min-height: 812px) { &:nth-child(1n + 11) { display: none; } } &:not([aria-selected]) .card__link:focus-visible, .tray__dialog:not([open]) & .card__link:focus-visible { --focus-ring-size: 0; } .tray__remove-pin-btn { --btn-icon-size: 1.25em; --btn-size: 2em; background-color: var(--card-bg-color); inset: 0 0 auto auto; opacity: 0.66; position: absolute; z-index: 1; &:hover { opacity: 1; } .tray__dialog:not([open]) & { opacity: 0; pointer-events: none; } [aria-busy] & { position: absolute !important; } } .avatar, .card__tags, .card__meta .btn, .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, .card__boosts, .card__comments, .card__closed { display: none; } .card__header { margin-block-start: calc(var(--card-padding-block) * -1.1); margin-inline: calc(-1 * var(--card-padding-inline)); max-inline-size: unset; } .card__body { display: block; margin-block-start: 0.3em; } .card__column-name--current { --btn-padding: 0.1em 0.5em; background: none !important; border: 1px solid currentColor; color: var(--color-ink); display: inline-flex; flex: 0 1 auto; inline-size: fit-content; margin: 0 0 0 auto; transition: translate 150ms ease-out; translate: -2em; .tray__dialog:not([open]) & { translate: 0; } } .card__link { z-index: 1; } .card__footer { margin-block: -0.2em 2em; .icon { display: none; } } .card__meta { grid-template-areas: "text-updated"; grid-template-columns: 1fr; } .card__meta-text { line-height: 1.5; } .card__meta-text--updated { border: 0; font-size: var(--text-x-small); opacity: 0.66; padding: 0; text-transform: none; .local-time-value { font-weight: inherit; } } .card__bubble { display: none; } } ::view-transition-group(tray-pins) { z-index: 100; } /* Notification-specific styles /* ------------------------------------------------------------------------ */ .tray--notifications { inset-inline: auto var(--tray-margin); view-transition-name: tray-notifications; #footer:has(.bar__placeholder[hidden]) & { inset-inline-end: -100%; } .tray__item, [data-navigable-list-target~=item] { [open] &[aria-selected] { outline: 0; z-index: calc(var(--tray-item-z) + 2); .card, .tray__item--hat & .btn { border-radius: 0.25ch; outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } &:nth-child(1n + 7) { display: none; pointer-events: none; visibility: hidden; } &:not([aria-selected]) .card:focus-visible, .tray__dialog:not([open]) & .card:focus-visible { --focus-ring-size: 0; } .btn:focus-visible { outline: 0; } } &:not(:has(.card--notification)) { .tray__notification-read-action { visibility: hidden; } } /* On mobile… */ @media (max-width: 799px) { /* …add a border */ .tray__dialog:not([open]) ~ .tray__toggle { border-inline-start: 1px dashed var(--color-ink-light); translate: var(--tray-margin) 0; } } } ::view-transition-group(tray-notifications) { z-index: 100; } } ================================================ FILE: app/assets/stylesheets/user.css ================================================ @layer components { .user-edit-link { inset: 0 0 auto auto; position: absolute; } } ================================================ FILE: app/assets/stylesheets/utilities.css ================================================ @layer utilities { /* Text */ .txt-xx-small { font-size: var(--text-xx-small); } .txt-x-small { font-size: var(--text-x-small); } .txt-small { font-size: var(--text-small); } .txt-normal { font-size: var(--text-normal); } .txt-medium { font-size: var(--text-medium); } .txt-large { font-size: var(--text-large); } .txt-x-large { font-size: var(--text-x-large); } .txt-xx-large { font-size: var(--text-xx-large); } .txt-align-center { text-align: center; } .txt-align-start { text-align: start; } .txt-align-end { text-align: end; } .txt-current { color: currentColor; } .txt-ink { color: var(--color-ink); } .txt-reversed { color: var(--color-ink-inverted); } .txt-negative { color: var(--color-negative); } .txt-positive { color: var(--color-positive); } .txt-subtle { color: var(--color-ink-dark); } .txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } .txt-underline { text-decoration: underline; } .txt-tight-lines { line-height: 1.2; } .txt-nowrap { white-space: nowrap; } .txt-balance { text-wrap: balance; } .txt-break { word-break: break-word; } .txt-uppercase { text-transform: uppercase; } .txt-capitalize { text-transform: capitalize; } .txt-capitalize-first-letter::first-letter { text-transform: capitalize; } .txt-link { color: var(--color-link); text-decoration: underline; } .font-weight-normal { font-weight: 400; } .font-weight-medium { font-weight: 500; } .font-weight-semibold { font-weight: 600; } .font-weight-bold { font-weight: 700; } .font-weight-black { font-weight: 900; } /* Flexbox and Grid */ .justify-end { justify-content: end; } .justify-start { justify-content: start; } .justify-center { justify-content: center; } .justify-space-between { justify-content: space-between; } .align-center { align-items: center; } .align-start { align-items: start; } .align-end { align-items: end; } .align-self-center { align-self: center; } .align-self-end { align-self: end; } .align-self-start { align-self: start; } .v-align-middle { vertical-align: middle; } .contain { contain: inline-size; } .display-inline { display: inline; } .flex { display: flex; } .flex-inline { display: inline-flex; } .flex-column { flex-direction: column; } .flex-wrap { flex-wrap: wrap; } .flex-1 { flex: 1; } .flex-item-grow { flex-grow: 1; } .flex-item-shrink { flex-shrink: 1; } .flex-item-no-shrink { flex-shrink: 0; } .flex-item-justify-start { margin-inline-end: auto; } .flex-item-justify-end { margin-inline-start: auto; } .gap { column-gap: var(--column-gap, var(--inline-space)); row-gap: var(--row-gap, var(--block-space)); } .gap-half { column-gap: var(--column-gap, var(--inline-space-half)); row-gap: var(--row-gap, var(--block-space-half)); } .gap-none { --column-gap: 0; --row-gap: 0; gap: 0; } /* Sizing */ .full-width { inline-size: 100%; } .min-width { min-inline-size: 0; } .half-width { inline-size: 50%; } .max-width { max-inline-size: 100%; } .min-content { inline-size: min-content; } .fit-content { inline-size: fit-content; } .max-inline-size { max-inline-size: 100%; } /* Overflow */ .overflow-x { overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; } .overflow-y { overflow-y: auto; scroll-snap-type: y mandatory; scroll-behavior: smooth; } .overflow-clip { text-overflow: clip; white-space: nowrap; overflow: hidden; } .overflow-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } .overflow-line-clamp { -webkit-line-clamp: var(--lines, 2); -webkit-box-orient: vertical; display: -webkit-box; overflow: hidden; text-overflow: clip; white-space: normal; } /* Mouse pointer */ .non-clickable { cursor: default; pointer-events: none; } .cursor-pointer { cursor: pointer; } /* Padding */ .pad { padding: var(--block-space) var(--inline-space); } .pad-double { padding: var(--block-space-double) var(--inline-space-double); } .pad-block { padding-block: var(--block-space); } .pad-block-start { padding-block-start: var(--block-space); } .pad-block-end { padding-block-end: var(--block-space); } .pad-block-half { padding-block: var(--block-space-half); } .pad-block-start-half { padding-block-start: var(--block-space-half); } .pad-inline { padding-inline: var(--inline-space); } .pad-inline-start { padding-inline-start: var(--inline-space); } .pad-inline-end { padding-inline-end: var(--inline-space); } .pad-inline-half { padding-inline: var(--inline-space-half); } .pad-inline-double { padding-inline: var(--inline-space-double); } .unpad { padding: 0; } .unpad-block-end { padding-block-end: 0; } .unpad-inline { padding-inline: 0; } /* Margins */ .margin { margin: var(--block-space) var(--inline-space); } .margin-block { margin-block: var(--block-space); } .margin-block-half { margin-block: var(--block-space-half); } .margin-block-start { margin-block-start: var(--block-space); } .margin-block-start-half { margin-block-start: var(--block-space-half); } .margin-block-start-auto { margin-block-start: auto; } .margin-block-end { margin-block-end: var(--block-space); } .margin-block-end-half { margin-block-end: var(--block-space-half); } .margin-block-double { margin-block: var(--block-space-double); } .margin-block-end-double { margin-block-end: var(--block-space-double); } .margin-block-start-double { margin-block-start: var(--block-space-double); } .margin-inline { margin-inline: var(--inline-space); } .margin-inline-start { margin-inline-start: var(--inline-space); } .margin-inline-start-half { margin-inline-start: var(--inline-space-half); } .margin-inline-end { margin-inline-end: var(--inline-space); } .margin-inline-end-half { margin-inline-end: var(--inline-space-half); } .margin-inline-half { margin-inline: var(--inline-space-half); } .margin-inline-double { margin-inline: var(--inline-space-double); } .margin-none { margin: 0; } .margin-none-block { margin-block: 0; } .margin-none-block-start { margin-block-start: 0; } .margin-none-block-end { margin-block-end: 0; } .margin-none-inline { margin-inline: 0; } .margin-none-inline-start { margin-inline-start: 0; } .margin-none-inline-end { margin-inline-end: 0; } .center { margin-inline: auto; } .center-block { margin-block: auto; } /* Position */ .position-relative { position: relative; } .position-sticky { position: sticky; inset: var(--inset, 0 auto auto auto); z-index: var(--z, 1); } /* Fills */ .fill { background-color: var(--color-canvas); } .fill-black { background-color: var(--color-ink); } .fill-white { background-color: var(--color-ink-inverted); } .fill-shade { background-color: var(--color-ink-lightest); } .fill-selected { background-color: var(--color-selected); } .fill-highlight { background-color: var(--color-highlight); } .fill-transparent { background-color: transparent; } .fill-highlighter { display: inline-block; position: relative; z-index: 1; &::before { background-color: var(--color-highlight); border-radius: 0.2em; content: ""; inset-block: 0; inset-inline: -0.1em; position: absolute; transform: skewX(-10deg) rotate(1deg); z-index: -1; } } .translucent { opacity: var(--opacity, 0.66); } /* Borders */ .border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } .border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } .border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } .border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); } .borderless { border: 0; } /* Border radius */ .border-radius { border-radius: var(--border-radius, 0.5em); } /* Shadows */ .shadow { box-shadow: var(--shadow); } /* Lists */ :where(.list-style-none) { list-style: none; margin: 0 auto; padding: 0; } /* Accessibility */ .visually-hidden, .for-screen-reader { block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; } /* Visibility */ [hidden] { display: none !important; } .display-contents, [contents] { display: contents; } .hide-in-pwa { @media (display-mode: standalone) { display: none; } } .hide-in-browser { @media (display-mode: browser) { display: none; } } .hide-focus-ring { --focus-ring-size: 0; } .hide-on-touch { @media (any-hover: none) { display: none; } } .show-on-touch { display: none; @media (any-hover: none) { display: unset; } } .show-on-native { body:not([data-platform~=native]) & { display: none; } } .hide-scrollbar { -ms-overflow-style: none; /* Edge */ scrollbar-width: none; /* FF */ /* Chrome/Safari/Opera */ &::-webkit-scrollbar { display: none; } } .hide-on-dark-mode { html[data-theme="dark"] & { display: none; } html:not([data-theme]) & { @media (prefers-color-scheme: dark) { display: none; } } } .hide-on-light-mode { html[data-theme="light"] & { display: none; } html:not([data-theme]) & { @media (prefers-color-scheme: light) { display: none; } } } } ================================================ FILE: app/assets/stylesheets/welcome-letter.css ================================================ @layer components { .welcome-letter { position: relative; view-transition-name: welcome-letter; z-index: var(--z-welcome); h2, p { text-wrap: pretty; } } .welcome-letter__close { inset: var(--block-space) var(--block-space) auto auto; position: absolute; } .welcome-letter__signature { background-color: currentColor; block-size: 3em; display: inline-block; inline-size: 8em; mask-image: url("jf-signature.svg"); mask-position: center; mask-repeat: no-repeat; mask-size: 8em 3em; } } ================================================ FILE: app/channels/application_cable/connection.rb ================================================ module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :current_user def connect set_current_user || reject_unauthorized_connection end private def set_current_user if session = find_session_by_cookie account = Account.find_by(external_account_id: request.env["fizzy.external_account_id"]) Current.account = account self.current_user = session.identity.users.find_by!(account: account) if account end end def find_session_by_cookie Session.find_signed(cookies.signed[:session_token]) end end end ================================================ FILE: app/controllers/account/cancellations_controller.rb ================================================ class Account::CancellationsController < ApplicationController before_action :ensure_owner def create Current.account.cancel redirect_to session_menu_path(script_name: nil), notice: "Account deleted" end private def ensure_owner head :forbidden unless Current.user.owner? end end ================================================ FILE: app/controllers/account/entropies_controller.rb ================================================ class Account::EntropiesController < ApplicationController wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ] before_action :ensure_admin def update @account = Current.account @account.entropy.update!(entropy_params) respond_to do |format| format.html { redirect_to account_settings_path, notice: "Account updated" } format.json { render "account/settings/show", status: :ok } end rescue ActiveRecord::RecordInvalid head :unprocessable_entity end private def entropy_params params.expect(entropy: [ :auto_postpone_period_in_days ]) end end ================================================ FILE: app/controllers/account/exports_controller.rb ================================================ class Account::ExportsController < ApplicationController before_action :ensure_admin_or_owner before_action :ensure_export_limit_not_exceeded, only: :create before_action :set_export, only: :show CURRENT_EXPORT_LIMIT = 10 def show respond_to do |format| format.html format.json { @export ? render(:show) : head(:not_found) } end end def create @export = Current.account.exports.create!(user: Current.user) @export.build_later respond_to do |format| format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." } format.json { render :show, status: :created } end end private def ensure_admin_or_owner head :forbidden unless Current.user.admin? || Current.user.owner? end def ensure_export_limit_not_exceeded head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT end def set_export scope = request.format.json? ? Current.account.exports : Current.account.exports.completed @export = scope.find_by(id: params[:id], user: Current.user) end end ================================================ FILE: app/controllers/account/imports_controller.rb ================================================ class Account::ImportsController < ApplicationController layout "public" disallow_account_scope only: %i[ new create ] allow_unauthorized_access only: :show before_action :set_import, only: %i[ show ] before_action :ensure_accessed_by_owner, only: %i[ show ] def new end def create signup = Signup.new(identity: Current.identity, full_name: "Import", skip_account_seeding: true) if signup.complete start_import(signup.account) else render :new, alert: "Couldn't create account." end end def show end private def set_import @import = Current.account.imports.find(params[:id]) end def ensure_accessed_by_owner head :forbidden unless @import.identity == Current.identity end def start_import(account) import = nil Current.set(account: account) do import = account.imports.create!(identity: Current.identity, file: params[:file]) import.process_later end redirect_to account_import_path(import, script_name: account.slug) end end ================================================ FILE: app/controllers/account/join_codes_controller.rb ================================================ class Account::JoinCodesController < ApplicationController wrap_parameters :account_join_code, include: %i[ usage_limit ] before_action :set_join_code before_action :ensure_admin, only: %i[ update destroy ] def show end def edit end def update if @join_code.update(join_code_params) respond_to do |format| format.html { redirect_to account_join_code_path } format.json { head :no_content } end else respond_to do |format| format.html { render :edit, status: :unprocessable_entity } format.json { render json: @join_code.errors, status: :unprocessable_entity } end end end def destroy @join_code.reset respond_to do |format| format.html { redirect_to account_join_code_path } format.json { head :no_content } end end private def set_join_code @join_code = Current.account.join_code end def join_code_params params.expect account_join_code: [ :usage_limit ] end end ================================================ FILE: app/controllers/account/settings_controller.rb ================================================ class Account::SettingsController < ApplicationController wrap_parameters :account, include: %i[ name ] before_action :ensure_admin, only: :update before_action :set_account def show respond_to do |format| format.html { @users = @account.users.active.alphabetically.includes(:identity) } format.json end end def update @account.update!(account_params) respond_to do |format| format.html { redirect_to account_settings_path } format.json { head :no_content } end end private def set_account @account = Current.account end def account_params params.expect account: %i[ name ] end end ================================================ FILE: app/controllers/admin_controller.rb ================================================ class AdminController < ApplicationController disallow_account_scope before_action :ensure_staff end ================================================ FILE: app/controllers/application_controller.rb ================================================ class ApplicationController < ActionController::Base include Authentication include Authorization include BlockSearchEngineIndexing include CurrentRequest, CurrentTimezone, SetPlatform include RequestForgeryProtection include TurboFlash, ViewTransitions include RoutingHeaders etag { "v1" } stale_when_importmap_changes allow_browser versions: :modern end ================================================ FILE: app/controllers/boards/columns/closeds_controller.rb ================================================ class Boards::Columns::ClosedsController < ApplicationController include BoardScoped def show set_page_and_extract_portion_from @board.cards.closed.recently_closed_first.preloaded fresh_when etag: @page.records end end ================================================ FILE: app/controllers/boards/columns/not_nows_controller.rb ================================================ class Boards::Columns::NotNowsController < ApplicationController include BoardScoped def show set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded fresh_when etag: @page.records end end ================================================ FILE: app/controllers/boards/columns/streams_controller.rb ================================================ class Boards::Columns::StreamsController < ApplicationController include BoardScoped def show set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded fresh_when etag: @page.records end end ================================================ FILE: app/controllers/boards/columns_controller.rb ================================================ class Boards::ColumnsController < ApplicationController wrap_parameters :column, include: %i[ name color ] include BoardScoped before_action :set_column, only: %i[ show update destroy ] def index @columns = @board.columns.sorted fresh_when etag: @columns end def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded fresh_when etag: @page.records end def create @column = @board.columns.create!(column_params) respond_to do |format| format.turbo_stream format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) } end end def update @column.update!(column_params) respond_to do |format| format.turbo_stream format.json { head :no_content } end end def destroy @column.destroy respond_to do |format| format.html { redirect_back_or_to @board } format.json { head :no_content } end end private def set_column @column = @board.columns.find(params[:id]) end def column_params params.expect(column: [ :name, :color ]) end end ================================================ FILE: app/controllers/boards/entropies_controller.rb ================================================ class Boards::EntropiesController < ApplicationController wrap_parameters :board, include: [ :auto_postpone_period_in_days ] include BoardScoped before_action :ensure_permission_to_admin_board def update @board.update!(entropy_params) respond_to do |format| format.turbo_stream format.json { render "boards/show", status: :ok } end rescue ActiveRecord::RecordInvalid head :unprocessable_entity end private def entropy_params params.expect(board: [ :auto_postpone_period_in_days ]) end end ================================================ FILE: app/controllers/boards/involvements_controller.rb ================================================ class Boards::InvolvementsController < ApplicationController include BoardScoped def update @board.access_for(Current.user).update!(involvement: params[:involvement]) respond_to do |format| format.html format.turbo_stream format.json { head :no_content } end end end ================================================ FILE: app/controllers/boards/publications_controller.rb ================================================ class Boards::PublicationsController < ApplicationController include BoardScoped before_action :ensure_permission_to_admin_board def create @board.publish respond_to do |format| format.turbo_stream format.json { render partial: "boards/board", locals: { board: @board }, status: :created } end end def destroy @board.unpublish @board.reload respond_to do |format| format.turbo_stream format.json { head :no_content } end end end ================================================ FILE: app/controllers/boards_controller.rb ================================================ class BoardsController < ApplicationController wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ] include FilterScoped before_action :set_board, except: %i[ index new create ] before_action :ensure_permission_to_admin_board, only: %i[ update destroy ] def index set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity) fresh_when etag: @page.records end def show if @filter.used?(ignore_boards: true) show_filtered_cards else show_columns end end def new @board = Board.new end def create @board = Board.create! board_params.with_defaults(all_access: true) respond_to do |format| format.html { redirect_to board_path(@board) } format.json { render :show, status: :created, location: board_path(@board, format: :json) } end end def edit selected_user_ids = @board.users.ids @selected_users, @unselected_users = \ @board.account.users.active.alphabetically.includes(:identity).partition { |user| selected_user_ids.include? user.id } end def update @board.update! board_params @board.accesses.revise granted: grantees, revoked: revokees if grantees_changed? respond_to do |format| format.html do if @board.accessible_to?(Current.user) redirect_to edit_board_path(@board), notice: "Saved" else redirect_to root_path, notice: "Saved (you were removed from the board)" end end format.json { head :no_content } end end def destroy @board.destroy respond_to do |format| format.html { redirect_to root_path } format.json { head :no_content } end end private def set_board @board = Current.user.boards.find params[:id] end def ensure_permission_to_admin_board unless Current.user.can_administer_board?(@board) head :forbidden end end def grantees_changed? params.key?(:user_ids) end def show_filtered_cards @filter.board_ids = [ @board.id ] set_page_and_extract_portion_from @filter.cards end def show_columns cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded set_page_and_extract_portion_from cards fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end def board_params params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ]) end def grantees @board.account.users.active.where id: grantee_ids end def revokees @board.users.where.not id: grantee_ids end def grantee_ids params.fetch :user_ids, [] end end ================================================ FILE: app/controllers/cards/assignments_controller.rb ================================================ class Cards::AssignmentsController < ApplicationController include CardScoped def new @assigned_to = @card.assignees.active.alphabetically.where.not(id: Current.user) @users = @board.users.active.alphabetically.where.not(id: @card.assignees).where.not(id: Current.user) fresh_when etag: [ @users, @card.assignees ] end def create if @card.toggle_assignment @board.users.active.find(params[:assignee_id]) respond_to do |format| format.turbo_stream format.json { head :no_content } end else respond_to do |format| format.turbo_stream format.json { head :unprocessable_entity } end end end end ================================================ FILE: app/controllers/cards/boards_controller.rb ================================================ class Cards::BoardsController < ApplicationController include BoardScoped skip_before_action :set_board, only: %i[ edit ] before_action :set_card def edit @boards = Current.user.boards.ordered_by_recently_accessed fresh_when @boards end def update @card.move_to(@board) respond_to do |format| format.html { redirect_to @card } format.json { head :no_content } end end private def set_card @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) end end ================================================ FILE: app/controllers/cards/closures_controller.rb ================================================ class Cards::ClosuresController < ApplicationController include CardScoped def create capture_card_location @card.close refresh_stream_if_needed respond_to do |format| format.turbo_stream format.json { head :no_content } end end def destroy @card.reopen refresh_stream_after_reopen respond_to do |format| format.turbo_stream format.json { head :no_content } end end private def refresh_stream_after_reopen if @card.awaiting_triage? set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded end end end ================================================ FILE: app/controllers/cards/columns_controller.rb ================================================ class Cards::ColumnsController < ApplicationController def edit @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) @columns = @card.board.columns.sorted fresh_when etag: [ @card, @columns ] end end ================================================ FILE: app/controllers/cards/comments/reactions_controller.rb ================================================ class Cards::Comments::ReactionsController < ApplicationController wrap_parameters :reaction, include: %i[ content ] include CardScoped before_action :set_comment before_action :set_reactable with_options only: :destroy do before_action :set_reaction before_action :ensure_permission_to_administer_reaction end def index render "reactions/index" end def new render "reactions/new" end def create @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) respond_to do |format| format.turbo_stream { render "reactions/create" } format.json { render "reactions/show", status: :created } end end def destroy @reaction.destroy respond_to do |format| format.turbo_stream { render "reactions/destroy" } format.json { head :no_content } end end private def set_comment @comment = @card.comments.find(params[:comment_id]) end def set_reactable @reactable = @comment end def set_reaction @reaction = @reactable.reactions.find(params[:id]) end def ensure_permission_to_administer_reaction head :forbidden if Current.user != @reaction.reacter end end ================================================ FILE: app/controllers/cards/comments_controller.rb ================================================ class Cards::CommentsController < ApplicationController wrap_parameters :comment, include: %i[ body created_at ] include CardScoped before_action :set_comment, only: %i[ show edit update destroy ] before_action :ensure_creatorship, only: %i[ edit update destroy ] before_action :ensure_card_is_commentable, only: :create def index set_page_and_extract_portion_from @card.comments.chronologically end def create @comment = @card.comments.create!(comment_params) respond_to do |format| format.turbo_stream format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) } end end def show end def edit end def update @comment.update! comment_params respond_to do |format| format.turbo_stream format.json { head :no_content } end end def destroy @comment.destroy respond_to do |format| format.turbo_stream format.json { head :no_content } end end private def set_comment @comment = @card.comments.find(params[:id]) end def ensure_creatorship head :forbidden if Current.user != @comment.creator end def ensure_card_is_commentable head :forbidden unless @card.commentable? end def comment_params params.expect(comment: [ :body, :created_at ]) end end ================================================ FILE: app/controllers/cards/drafts_controller.rb ================================================ class Cards::DraftsController < ApplicationController include CardScoped before_action :redirect_if_published def show end private def redirect_if_published redirect_to @card unless @card.drafted? end end ================================================ FILE: app/controllers/cards/goldnesses_controller.rb ================================================ class Cards::GoldnessesController < ApplicationController include CardScoped def create @card.gild respond_to do |format| format.turbo_stream { render_card_replacement } format.json { head :no_content } end end def destroy @card.ungild respond_to do |format| format.turbo_stream { render_card_replacement } format.json { head :no_content } end end end ================================================ FILE: app/controllers/cards/images_controller.rb ================================================ class Cards::ImagesController < ApplicationController include CardScoped def destroy @card.image.purge_later respond_to do |format| format.html { redirect_to @card } format.json { head :no_content } end end end ================================================ FILE: app/controllers/cards/not_nows_controller.rb ================================================ class Cards::NotNowsController < ApplicationController include CardScoped def create capture_card_location @card.postpone refresh_stream_if_needed respond_to do |format| format.turbo_stream format.json { head :no_content } end end end ================================================ FILE: app/controllers/cards/pins_controller.rb ================================================ class Cards::PinsController < ApplicationController include CardScoped def show fresh_when etag: @card.pin_for(Current.user) || "none" end def create @pin = @card.pin_by Current.user broadcast_add_pin_to_tray respond_to do |format| format.turbo_stream { render_pin_button_replacement } format.json { head :no_content } end end def destroy @pin = @card.unpin_by Current.user broadcast_remove_pin_from_tray respond_to do |format| format.turbo_stream { render_pin_button_replacement } format.json { head :no_content } end end private def broadcast_add_pin_to_tray @pin.broadcast_prepend_to [ Current.user, :pins_tray ], target: "pins", partial: "my/pins/pin" end def broadcast_remove_pin_from_tray @pin.broadcast_remove_to [ Current.user, :pins_tray ] end def render_pin_button_replacement render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card }) end end ================================================ FILE: app/controllers/cards/previews_controller.rb ================================================ class Cards::PreviewsController < ApplicationController include FilterScoped before_action :set_filter, only: :index def index set_page_and_extract_portion_from @filter.cards end end ================================================ FILE: app/controllers/cards/publishes_controller.rb ================================================ class Cards::PublishesController < ApplicationController include CardScoped def create @card.publish respond_to do |format| format.html do if add_another_param? card = @board.cards.create!(status: :drafted) redirect_to card_draft_path(card), notice: "Card added" else redirect_to @card.board end end format.json { head :created } end end private def add_another_param? params[:creation_type] == "add_another" end end ================================================ FILE: app/controllers/cards/reactions_controller.rb ================================================ class Cards::ReactionsController < ApplicationController wrap_parameters :reaction, include: %i[ content ] include CardScoped before_action :set_reactable with_options only: :destroy do before_action :set_reaction before_action :ensure_permission_to_administer_reaction end def index render "reactions/index" end def new render "reactions/new" end def create @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) respond_to do |format| format.turbo_stream { render "reactions/create" } format.json { render "reactions/show", status: :created } end end def destroy @reaction.destroy respond_to do |format| format.turbo_stream { render "reactions/destroy" } format.json { head :no_content } end end private def set_reactable @reactable = @card end def set_reaction @reaction = @reactable.reactions.find(params[:id]) end def ensure_permission_to_administer_reaction head :forbidden if Current.user != @reaction.reacter end end ================================================ FILE: app/controllers/cards/readings_controller.rb ================================================ class Cards::ReadingsController < ApplicationController include CardScoped def create @notification = @card.read_by(Current.user) record_board_access respond_to do |format| format.turbo_stream format.json { head :created } end end def destroy @notification = @card.unread_by(Current.user) record_board_access respond_to do |format| format.turbo_stream format.json { head :no_content } end end private def record_board_access @card.board.accessed_by(Current.user) end end ================================================ FILE: app/controllers/cards/self_assignments_controller.rb ================================================ class Cards::SelfAssignmentsController < ApplicationController include CardScoped def create if @card.toggle_assignment(Current.user) respond_to do |format| format.turbo_stream { render "cards/assignments/create" } format.json { head :no_content } end else respond_to do |format| format.turbo_stream { render "cards/assignments/create" } format.json { head :unprocessable_entity } end end end end ================================================ FILE: app/controllers/cards/steps_controller.rb ================================================ class Cards::StepsController < ApplicationController wrap_parameters :step, include: %i[ content completed ] include CardScoped before_action :set_step, only: %i[ show edit update destroy ] def index fresh_when etag: @card.steps end def create @step = @card.steps.create!(step_params) respond_to do |format| format.turbo_stream format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) } end end def show end def edit end def update @step.update!(step_params) respond_to do |format| format.turbo_stream format.json { render :show } end end def destroy @step.destroy! respond_to do |format| format.turbo_stream format.json { head :no_content } end end private def set_step @step = @card.steps.find(params[:id]) end def step_params params.expect(step: [ :content, :completed ]) end end ================================================ FILE: app/controllers/cards/taggings_controller.rb ================================================ class Cards::TaggingsController < ApplicationController include CardScoped def new @tagged_with = @card.tags.alphabetically @tags = Current.account.tags.all.alphabetically.where.not(id: @tagged_with) fresh_when etag: [ @tags, @card.tags ] end def create @card.toggle_tag_with sanitized_tag_title_param respond_to do |format| format.turbo_stream format.json { head :no_content } end end private def sanitized_tag_title_param params.required(:tag_title).strip.gsub(/\A#/, "") end end ================================================ FILE: app/controllers/cards/triages_controller.rb ================================================ class Cards::TriagesController < ApplicationController include CardScoped def create column = @card.board.columns.find(params[:column_id]) @card.triage_into(column) respond_to do |format| format.html { redirect_to @card } format.json { head :no_content } end end def destroy @card.send_back_to_triage respond_to do |format| format.html { redirect_to @card } format.json { head :no_content } end end end ================================================ FILE: app/controllers/cards/watches_controller.rb ================================================ class Cards::WatchesController < ApplicationController include CardScoped def show fresh_when etag: @card.watch_for(Current.user) || "none" end def create @card.watch_by Current.user respond_to do |format| format.turbo_stream format.json { head :no_content } end end def destroy @card.unwatch_by Current.user respond_to do |format| format.turbo_stream format.json { head :no_content } end end end ================================================ FILE: app/controllers/cards_controller.rb ================================================ class CardsController < ApplicationController wrap_parameters :card, include: %i[ title description image created_at last_active_at ] include FilterScoped before_action :set_board, only: %i[ create ] before_action :set_card, only: %i[ show edit update destroy ] before_action :redirect_if_drafted, only: :show before_action :ensure_permission_to_administer_card, only: %i[ destroy ] def index set_page_and_extract_portion_from @filter.cards end def create respond_to do |format| format.html do card = Current.user.draft_new_card_in(@board) redirect_to card_draft_path(card) end format.json do @card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") render :show, status: :created, location: card_path(@card, format: :json) end end end def show end def edit end def update @card.update! card_params respond_to do |format| format.turbo_stream format.json { render :show } end end def destroy @card.destroy! respond_to do |format| format.html { redirect_to @card.board, notice: "Card deleted" } format.json { head :no_content } end end private def set_board @board = Current.user.boards.find params[:board_id] end def set_card @card = Current.user.accessible_cards.find_by!(number: params[:id]) end def redirect_if_drafted redirect_to card_draft_path(@card) if @card.drafted? end def ensure_permission_to_administer_card head :forbidden unless Current.user.can_administer_card?(@card) end def card_params params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ]) end end ================================================ FILE: app/controllers/client_configurations_controller.rb ================================================ class ClientConfigurationsController < ApplicationController skip_before_action :require_account, :require_authentication allow_unauthorized_access def show expires_in 1.minute, public: true render action: client_configuration_name end private def client_configuration_name "#{params.require(:platform)}_v#{params.require(:version)}" end end ================================================ FILE: app/controllers/columns/cards/drops/closures_controller.rb ================================================ class Columns::Cards::Drops::ClosuresController < ApplicationController include CardScoped def create @card.close end end ================================================ FILE: app/controllers/columns/cards/drops/columns_controller.rb ================================================ class Columns::Cards::Drops::ColumnsController < ApplicationController include CardScoped def create @column = @card.board.columns.find(params[:column_id]) @card.triage_into(@column) end end ================================================ FILE: app/controllers/columns/cards/drops/not_nows_controller.rb ================================================ class Columns::Cards::Drops::NotNowsController < ApplicationController include CardScoped def create @card.postpone end end ================================================ FILE: app/controllers/columns/cards/drops/streams_controller.rb ================================================ class Columns::Cards::Drops::StreamsController < ApplicationController include CardScoped def create @card.send_back_to_triage set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first end end ================================================ FILE: app/controllers/columns/left_positions_controller.rb ================================================ class Columns::LeftPositionsController < ApplicationController include ColumnScoped def create @left_column = @column.left_column @column.move_left respond_to do |format| format.turbo_stream format.json { head :created } end end end ================================================ FILE: app/controllers/columns/right_positions_controller.rb ================================================ class Columns::RightPositionsController < ApplicationController include ColumnScoped def create @right_column = @column.right_column @column.move_right respond_to do |format| format.turbo_stream format.json { head :created } end end end ================================================ FILE: app/controllers/concerns/authentication/via_magic_link.rb ================================================ module Authentication::ViaMagicLink extend ActiveSupport::Concern included do after_action :ensure_development_magic_link_not_leaked end private def ensure_development_magic_link_not_leaked unless Rails.env.development? raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? end end def redirect_to_fake_session_magic_link(email_address, **options) fake_magic_link = MagicLink.new( identity: Identity.new(email_address: email_address), code: SecureRandom.base32(6), expires_at: MagicLink::EXPIRATION_TIME.from_now ) redirect_to_session_magic_link fake_magic_link, **options end def redirect_to_session_magic_link(magic_link, return_to: nil) serve_development_magic_link(magic_link) set_pending_authentication_token(magic_link) session[:return_to_after_authenticating] = return_to if return_to respond_to do |format| format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } end end def serve_development_magic_link(magic_link) if Rails.env.development? && magic_link.present? flash[:magic_link_code] = magic_link.code response.set_header("X-Magic-Link-Code", magic_link.code) end end def set_pending_authentication_token(magic_link) cookies[:pending_authentication_token] = { value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at), httponly: true, same_site: :lax, expires: magic_link.expires_at } end def email_address_pending_authentication pending_authentication_token_verifier.verified(pending_authentication_token) end def pending_authentication_token_verifier Rails.application.message_verifier(:pending_authentication) end def pending_authentication_token cookies[:pending_authentication_token] end def clear_pending_authentication_token cookies.delete(:pending_authentication_token) end end ================================================ FILE: app/controllers/concerns/authentication.rb ================================================ module Authentication extend ActiveSupport::Concern included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication helper_method :authenticated? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } include Authentication::ViaMagicLink, LoginHelper end class_methods do def require_unauthenticated_access(**options) allow_unauthenticated_access **options before_action :redirect_authenticated_user, **options end def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options before_action :resume_session, **options allow_unauthorized_access **options end def disallow_account_scope(**options) skip_before_action :require_account, **options before_action :redirect_tenanted_request, **options end end private def authenticated? Current.identity.present? end def require_account unless Current.account.present? redirect_to main_app.session_menu_path(script_name: nil) end end def require_authentication resume_session || authenticate_by_bearer_token || request_authentication end def resume_session if session = find_session_by_cookie set_current_session session end end def find_session_by_cookie Session.find_signed(cookies.signed[:session_token]) end def authenticate_by_bearer_token if request.authorization.to_s.include?("Bearer") authenticate_or_request_with_http_token do |token| if identity = Identity.find_by_permissable_access_token(token, method: request.method) Current.identity = identity end end end end def request_authentication if Current.account.present? session[:return_to_after_authenticating] = request.url end redirect_to_login_url end def after_authentication_url session.delete(:return_to_after_authenticating) || landing_url end def redirect_authenticated_user redirect_to main_app.root_url if authenticated? end def redirect_tenanted_request redirect_to main_app.root_url if Current.account.present? end def start_new_session_for(identity) identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| set_current_session session end end def set_current_session(session) Current.session = session cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end def terminate_session Current.session.destroy cookies.delete(:session_token) end def session_token cookies[:session_token] end end ================================================ FILE: app/controllers/concerns/authorization.rb ================================================ module Authorization extend ActiveSupport::Concern included do before_action :ensure_can_access_account, if: :authenticated_account_access? end class_methods do def allow_unauthorized_access(**options) skip_before_action :ensure_can_access_account, **options end def require_access_without_a_user(**options) skip_before_action :ensure_can_access_account, **options before_action :redirect_existing_user, **options end end private def ensure_admin head :forbidden unless Current.user.admin? end def ensure_staff head :forbidden unless Current.identity.staff? end def authenticated_account_access? Current.account.present? && authenticated? end def ensure_can_access_account unless Current.account.active? && Current.user&.active? respond_to do |format| format.html { redirect_to session_menu_path(script_name: nil) } format.json { head :forbidden } end end end def redirect_existing_user redirect_to root_path if Current.user end end ================================================ FILE: app/controllers/concerns/block_search_engine_indexing.rb ================================================ # Tell crawlers like Googlebot to drop pages entirely from search results, even # if other sites link to it module BlockSearchEngineIndexing extend ActiveSupport::Concern included do after_action :block_search_engine_indexing end private def block_search_engine_indexing headers["X-Robots-Tag"] = "none" end end ================================================ FILE: app/controllers/concerns/board_scoped.rb ================================================ module BoardScoped extend ActiveSupport::Concern included do before_action :set_board end private def set_board @board = Current.user.boards.find(params[:board_id]) end def ensure_permission_to_admin_board unless Current.user.can_administer_board?(@board) head :forbidden end end end ================================================ FILE: app/controllers/concerns/card_scoped.rb ================================================ module CardScoped extend ActiveSupport::Concern included do before_action :set_card, :set_board end private def set_card @card = Current.user.accessible_cards.find_by!(number: params[:card_id]) end def set_board @board = @card.board end def render_card_replacement render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) end def capture_card_location @source_column = @card.column @was_in_stream = @card.awaiting_triage? end def refresh_stream_if_needed if @was_in_stream set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded end end end ================================================ FILE: app/controllers/concerns/column_scoped.rb ================================================ module ColumnScoped extend ActiveSupport::Concern included do before_action :set_column end private def set_column @column = Current.user.accessible_columns.find(params[:column_id]) end end ================================================ FILE: app/controllers/concerns/current_request.rb ================================================ module CurrentRequest extend ActiveSupport::Concern included do before_action do Current.http_method = request.method Current.request_id = request.uuid Current.user_agent = request.user_agent Current.ip_address = request.ip Current.referrer = request.referrer end end end ================================================ FILE: app/controllers/concerns/current_timezone.rb ================================================ # FIXME: This should move upstream to Rails. It's a good pattern. module CurrentTimezone extend ActiveSupport::Concern included do around_action :set_current_timezone helper_method :timezone_from_cookie etag { timezone_from_cookie } end private def set_current_timezone(&) Time.use_zone(timezone_from_cookie, &) end def timezone_from_cookie @timezone_from_cookie ||= begin timezone = cookies[:timezone] ActiveSupport::TimeZone[timezone] if timezone.present? end end end ================================================ FILE: app/controllers/concerns/day_timelines_scoped.rb ================================================ module DayTimelinesScoped extend ActiveSupport::Concern included do include FilterScoped before_action :set_day_timeline end private def set_day_timeline @day_timeline = Current.user.timeline_for(day, filter: @filter) end def day if params[:day].present? Time.zone.parse(params[:day]) else Time.current end rescue ArgumentError head :not_found end end ================================================ FILE: app/controllers/concerns/filter_scoped.rb ================================================ module FilterScoped extend ActiveSupport::Concern included do before_action :set_filter before_action :set_user_filtering end private def set_filter if params[:filter_id].present? @filter = Current.user.filters.find(params[:filter_id]) else @filter = Current.user.filters.from_params filter_params end end def filter_params params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) end def set_user_filtering @user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param) end def expanded_param ActiveRecord::Type::Boolean.new.cast(params[:expand_all]) end end ================================================ FILE: app/controllers/concerns/request_forgery_protection.rb ================================================ module RequestForgeryProtection extend ActiveSupport::Concern included do protect_from_forgery using: :header_only, with: :exception end private def verified_via_header_only? super || allowed_api_request? end def allowed_api_request? sec_fetch_site_value.nil? && request.format.json? end end ================================================ FILE: app/controllers/concerns/routing_headers.rb ================================================ module RoutingHeaders extend ActiveSupport::Concern included do before_action :set_target_header end private def set_target_header response.headers["X-Kamal-Target"] = request.headers["X-Kamal-Target"] end end ================================================ FILE: app/controllers/concerns/set_platform.rb ================================================ module SetPlatform extend ActiveSupport::Concern included do helper_method :platform end private def platform @platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent) end end ================================================ FILE: app/controllers/concerns/turbo_flash.rb ================================================ module TurboFlash extend ActiveSupport::Concern included do helper_method :turbo_stream_flash end private def turbo_stream_flash(**flash_options) turbo_stream.replace(:flash, partial: "layouts/shared/flash", locals: { flash: flash_options }) end end ================================================ FILE: app/controllers/concerns/view_transitions.rb ================================================ # FIXME: Upstream this fix to turbo-rails module ViewTransitions extend ActiveSupport::Concern included do before_action :disable_view_transitions, if: :page_refresh? end private def disable_view_transitions @disable_view_transition = true end def page_refresh? request.referrer.present? && request.referrer == request.url end end ================================================ FILE: app/controllers/events/day_timeline/columns_controller.rb ================================================ class Events::DayTimeline::ColumnsController < ApplicationController include DayTimelinesScoped before_action :ensure_valid_column before_action :set_column def show fresh_when @day_timeline end private VALID_COLUMNS = %w[ added updated closed ] def ensure_valid_column head :not_found unless VALID_COLUMNS.include?(params[:id]) end def set_column @column = @day_timeline.public_send("#{params[:id]}_column") end end ================================================ FILE: app/controllers/events/days_controller.rb ================================================ class Events::DaysController < ApplicationController include DayTimelinesScoped def index fresh_when @day_timeline end end ================================================ FILE: app/controllers/events_controller.rb ================================================ class EventsController < ApplicationController include DayTimelinesScoped def index fresh_when @day_timeline end end ================================================ FILE: app/controllers/filters/settings_refreshes_controller.rb ================================================ class Filters::SettingsRefreshesController < ApplicationController include FilterScoped def create end end ================================================ FILE: app/controllers/filters_controller.rb ================================================ class FiltersController < ApplicationController before_action :set_filters def create @filter = Current.user.filters.remember filter_params end def destroy @filter = Current.user.filters.find(params[:id]) @filter.destroy! end private def set_filters @filters = Current.user.filters end def filter_params Filter.normalize_params(params.permit(*Filter::PERMITTED_PARAMS)) end end ================================================ FILE: app/controllers/join_codes_controller.rb ================================================ class JoinCodesController < ApplicationController allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests } before_action :set_join_code before_action :ensure_join_code_is_valid before_action :set_identity, only: :create layout "public" def new end def create @join_code.redeem_if { |account| @identity.join(account) } user = User.active.find_by!(account: @join_code.account, identity: @identity) if @identity == Current.identity && user.setup? redirect_to landing_url(script_name: @join_code.account.slug) elsif @identity == Current.identity redirect_to new_users_verification_url(script_name: @join_code.account.slug) else terminate_session if Current.identity redirect_to_session_magic_link \ @identity.send_magic_link, return_to: new_users_verification_url(script_name: @join_code.account.slug) end end private def set_identity @identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address)) if @identity.new_record? if @identity.invalid? head :unprocessable_entity else @identity.save! end end end def set_join_code @join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account) end def ensure_join_code_is_valid if @join_code.nil? head :not_found elsif !@join_code.active? render :inactive, status: :gone end end end ================================================ FILE: app/controllers/landings_controller.rb ================================================ class LandingsController < ApplicationController def show flash.keep(:welcome_letter) if Current.user.boards.one? redirect_to board_path(Current.user.boards.first) else redirect_to root_path end end end ================================================ FILE: app/controllers/my/access_tokens_controller.rb ================================================ class My::AccessTokensController < ApplicationController wrap_parameters :access_token, include: %i[ description permission ] skip_before_action :require_account def index @access_tokens = my_access_tokens.order(created_at: :desc) end def show @access_token = my_access_tokens.find(verifier.verify(params[:id])) rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to my_access_tokens_path, alert: "Token is no longer visible" end def new @access_token = my_access_tokens.new end def create access_token = my_access_tokens.create!(access_token_params) respond_to do |format| format.html do expiring_id = verifier.generate access_token.id, expires_in: 10.seconds redirect_to my_access_token_path(expiring_id) end format.json do render status: :created, json: \ { id: access_token.id, token: access_token.token, description: access_token.description, permission: access_token.permission, created_at: access_token.created_at.utc } end end end def destroy my_access_tokens.find(params[:id]).destroy! respond_to do |format| format.html { redirect_to my_access_tokens_path } format.json { head :no_content } end end private def my_access_tokens Current.identity.access_tokens end def access_token_params params.expect(access_token: %i[ description permission ]) end def verifier Rails.application.message_verifier(:access_tokens) end end ================================================ FILE: app/controllers/my/identities_controller.rb ================================================ class My::IdentitiesController < ApplicationController disallow_account_scope def show @identity = Current.identity end end ================================================ FILE: app/controllers/my/menus_controller.rb ================================================ class My::MenusController < ApplicationController def show @filters = Current.user.filters.all @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically @accounts = Current.identity.accounts.active fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end end ================================================ FILE: app/controllers/my/passkey_challenges_controller.rb ================================================ class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController include Authentication include Authorization allow_unauthenticated_access disallow_account_scope end ================================================ FILE: app/controllers/my/passkeys_controller.rb ================================================ class My::PasskeysController < ApplicationController include ActionPack::Passkey::Request before_action :set_passkey, only: %i[ edit update destroy ] def index @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc) @creation_options = passkey_creation_options(holder: Current.identity) end def create passkey = Current.identity.passkeys.register(passkey_creation_params) redirect_to edit_my_passkey_path(passkey, created: true) end def edit end def update @passkey.update!(params.expect(passkey: [ :name ])) redirect_to my_passkeys_path end def destroy @passkey.destroy! redirect_to my_passkeys_path end private def set_passkey @passkey = Current.identity.passkeys.find(params[:id]) end end ================================================ FILE: app/controllers/my/pins_controller.rb ================================================ class My::PinsController < ApplicationController def index @pins = user_pins fresh_when etag: [ @pins, @pins.collect(&:card) ] end private def user_pins Current.user.pins.includes(:card).ordered.limit(pins_limit) end def pins_limit request.format.json? ? 100 : 20 end end ================================================ FILE: app/controllers/my/timezones_controller.rb ================================================ class My::TimezonesController < ApplicationController def update Current.user.settings.update!(timezone_name: timezone_param) end private def timezone_param params[:timezone_name] end end ================================================ FILE: app/controllers/notifications/bulk_readings_controller.rb ================================================ class Notifications::BulkReadingsController < ApplicationController def create Current.user.notifications.unread.read_all respond_to do |format| format.html do if from_tray? head :ok else redirect_to notifications_path end end format.json { head :no_content } end end private def from_tray? params[:from_tray] end end ================================================ FILE: app/controllers/notifications/readings_controller.rb ================================================ class Notifications::ReadingsController < ApplicationController def create @notification = Current.user.notifications.find(params[:notification_id]) @notification.read respond_to do |format| format.turbo_stream format.json { head :no_content } end end def destroy @notification = Current.user.notifications.find(params[:notification_id]) @notification.unread respond_to do |format| format.turbo_stream format.json { head :no_content } end end end ================================================ FILE: app/controllers/notifications/settings_controller.rb ================================================ class Notifications::SettingsController < ApplicationController wrap_parameters :user_settings, include: %i[ bundle_email_frequency ] before_action :set_settings def show @boards = Current.user.boards.alphabetically end def update @settings.update!(settings_params) respond_to do |format| format.html { redirect_to notifications_settings_path, notice: "Settings updated" } format.json { head :no_content } end end private def set_settings @settings = Current.user.settings end def settings_params params.expect(user_settings: :bundle_email_frequency) end end ================================================ FILE: app/controllers/notifications/trays_controller.rb ================================================ class Notifications::TraysController < ApplicationController MAX_ENTRIES_LIMIT = 100 def show @notifications = unread_notifications if include_read? @notifications += read_notifications end # Invalidate on the whole set instead of the unread set since the max updated at in the unread set # can stay the same when reading old notifications. fresh_when etag: [ Current.user.notifications, include_read? ] end private def unread_notifications Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) end def read_notifications Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT) end def include_read? ActiveModel::Type::Boolean.new.cast(params[:include_read]) end end ================================================ FILE: app/controllers/notifications/unsubscribes_controller.rb ================================================ class Notifications::UnsubscribesController < ApplicationController allow_unauthenticated_access skip_forgery_protection before_action :set_user def new end def create @user.settings.bundle_email_never! redirect_to notifications_unsubscribe_path(access_token: params[:access_token]) end def show end private def set_user unless @user = User.find_by_token_for(:unsubscribe, params[:access_token]) redirect_to root_path, alert: "Invalid unsubscribe link" end end end ================================================ FILE: app/controllers/notifications_controller.rb ================================================ class NotificationsController < ApplicationController MAX_UNREAD_NOTIFICATIONS = 500 MAX_UNREAD_NOTIFICATIONS_VIA_API = 100 def index @unread = Current.user.notifications.unread.ordered.preloaded.limit(max_unread_notifications) unless current_page_param set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded respond_to do |format| format.turbo_stream if current_page_param # Allows read-all action to side step pagination format.html format.json end end private def max_unread_notifications request.format.json? ? MAX_UNREAD_NOTIFICATIONS_VIA_API : MAX_UNREAD_NOTIFICATIONS end end ================================================ FILE: app/controllers/prompts/boards/users_controller.rb ================================================ class Prompts::Boards::UsersController < ApplicationController include BoardScoped def index @users = @board.users.active.alphabetically if stale? etag: @users render layout: false end end end ================================================ FILE: app/controllers/prompts/cards_controller.rb ================================================ class Prompts::CardsController < ApplicationController MAX_RESULTS = 10 def index @cards = if filter_param.present? prepending_exact_matches_by_id(search_cards) else published_cards.latest end if stale? etag: @cards render layout: false end end private def filter_param params[:filter] end def search_cards published_cards .mentioning(params[:filter], user: Current.user) .reverse_chronologically .limit(MAX_RESULTS) end def published_cards Current.user.accessible_cards.published end def prepending_exact_matches_by_id(cards) if card_by_id = Current.user.accessible_cards.find_by(number: params[:filter]) [ card_by_id ] + cards else cards end end end ================================================ FILE: app/controllers/prompts/tags_controller.rb ================================================ class Prompts::TagsController < ApplicationController def index @tags = Current.account.tags.all.alphabetically if stale? etag: @tags render layout: false end end end ================================================ FILE: app/controllers/prompts/users_controller.rb ================================================ class Prompts::UsersController < ApplicationController def index @users = Current.account.users.active.alphabetically if stale? etag: @users render layout: false end end end ================================================ FILE: app/controllers/public/base_controller.rb ================================================ class Public::BaseController < ApplicationController allow_unauthenticated_access before_action :set_board, :set_card, :set_public_cache_expiration before_action :ensure_board_accessible layout "public" private def set_board @board = Board.find_by_published_key(params[:board_id] || params[:id]) end def set_card @card = @board.cards.published.find_by!(number: params[:id]) if params[:board_id] && params[:id] end def set_public_cache_expiration expires_in 30.seconds, public: true end def ensure_board_accessible raise ActionController::RoutingError, "Not Found" if @board&.account&.cancelled? end end ================================================ FILE: app/controllers/public/boards/columns/closeds_controller.rb ================================================ class Public::Boards::Columns::ClosedsController < Public::BaseController def show set_page_and_extract_portion_from @board.cards.closed.published.recently_closed_first end end ================================================ FILE: app/controllers/public/boards/columns/not_nows_controller.rb ================================================ class Public::Boards::Columns::NotNowsController < Public::BaseController def show set_page_and_extract_portion_from @board.cards.postponed.latest end end ================================================ FILE: app/controllers/public/boards/columns/streams_controller.rb ================================================ class Public::Boards::Columns::StreamsController < Public::BaseController def show set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first end end ================================================ FILE: app/controllers/public/boards/columns_controller.rb ================================================ class Public::Boards::ColumnsController < Public::BaseController before_action :set_column def show set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first end private # Unlike the other public controllers, this is using params[:id] to fetch the column instead of the card def set_card end def set_column @column = @board.columns.find(params[:id]) end end ================================================ FILE: app/controllers/public/boards_controller.rb ================================================ class Public::BoardsController < Public::BaseController def show set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first end end ================================================ FILE: app/controllers/public/cards_controller.rb ================================================ class Public::CardsController < Public::BaseController def show end end ================================================ FILE: app/controllers/pwa_controller.rb ================================================ class PwaController < ApplicationController disallow_account_scope skip_forgery_protection # We need a stable URL at the root, so we can't use the regular asset path here. def service_worker end end ================================================ FILE: app/controllers/qr_codes_controller.rb ================================================ class QrCodesController < ApplicationController allow_unauthenticated_access def show expires_in 1.year, public: true qr_code_svg = RQRCode::QRCode .new(QrCodeLink.from_signed(params[:id]).url) .as_svg(viewbox: true, fill: :white, color: :black, offset: 16) render svg: qr_code_svg end end ================================================ FILE: app/controllers/searches/queries_controller.rb ================================================ class Searches::QueriesController < ApplicationController def create Current.user.remember_search(params[:q]) head :ok end end ================================================ FILE: app/controllers/searches_controller.rb ================================================ class SearchesController < ApplicationController include Turbo::DriveHelper def show @query = params[:q].blank? ? nil : params[:q] if card = Current.user.accessible_cards.find_by_id(@query) respond_to do |format| format.html { @card = card } format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) } end else respond_to do |format| format.html do set_page_and_extract_portion_from Current.user.search(@query) @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) end format.json do set_page_and_extract_portion_from \ Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded end end end end end ================================================ FILE: app/controllers/sessions/magic_links_controller.rb ================================================ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" def show end def create if magic_link = MagicLink.consume(code) authenticate magic_link else invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? alert_message = "Enter your email address to sign in." respond_to do |format| format.html { redirect_to new_session_path, alert: alert_message } format.json { render json: { message: alert_message }, status: :unauthorized } end end end def code params.expect(:code) end def authenticate(magic_link) if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) sign_in magic_link else email_address_mismatch end end def sign_in(magic_link) clear_pending_authentication_token start_new_session_for magic_link.identity respond_to do |format| format.html { redirect_to after_sign_in_url(magic_link) } format.json { render json: { session_token: session_token, requires_signup_completion: requires_signup_completion?(magic_link) } } end end def email_address_mismatch clear_pending_authentication_token alert_message = "Something went wrong. Please try again." respond_to do |format| format.html { redirect_to new_session_path, alert: alert_message } format.json { render json: { message: alert_message }, status: :unauthorized } end end def invalid_code respond_to do |format| format.html { redirect_to session_magic_link_path, flash: { shake: true } } format.json { render json: { message: "Try another code." }, status: :unauthorized } end end def after_sign_in_url(magic_link) if requires_signup_completion?(magic_link) new_signup_completion_path else after_authentication_url end end def rate_limit_exceeded rate_limit_exceeded_message = "Try again in 15 minutes." respond_to do |format| format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end def requires_signup_completion?(magic_link) magic_link.for_sign_up? end end ================================================ FILE: app/controllers/sessions/menus_controller.rb ================================================ class Sessions::MenusController < ApplicationController disallow_account_scope layout "public" def show @accounts = Current.identity.accounts.active if @accounts.one? redirect_to root_url(script_name: @accounts.first.slug) end end end ================================================ FILE: app/controllers/sessions/passkeys_controller.rb ================================================ class Sessions::PasskeysController < ApplicationController include ActionPack::Passkey::Request disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded def create if credential = ActionPack::Passkey.authenticate(passkey_request_params) start_new_session_for credential.holder respond_to do |format| format.html { redirect_to after_authentication_url } format.json { render json: { session_token: session_token } } end else respond_to do |format| format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." } format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized } end end end private def rate_limit_exceeded rate_limit_exceeded_message = "Try again later." respond_to do |format| format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end end ================================================ FILE: app/controllers/sessions/transfers_controller.rb ================================================ class Sessions::TransfersController < ApplicationController disallow_account_scope require_unauthenticated_access def show end def update if identity = Identity.find_by_transfer_id(params[:id]) start_new_session_for identity redirect_to session_menu_path(script_name: nil) else head :bad_request end end end ================================================ FILE: app/controllers/sessions_controller.rb ================================================ class SessionsController < ApplicationController include ActionPack::Passkey::Request disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" def new @request_options = passkey_request_options end def create if identity = Identity.find_by(email_address: email_address) sign_in identity elsif Account.accepting_signups? sign_up else redirect_to_fake_session_magic_link email_address end end def destroy terminate_session respond_to do |format| format.html { redirect_to_logout_url } format.json { head :no_content } end end private def magic_link_from_sign_in_or_sign_up if identity = Identity.find_by_email_address(email_address) identity.send_magic_link else signup = Signup.new(email_address: email_address) signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? end end def email_address params.expect(:email_address) end def rate_limit_exceeded rate_limit_exceeded_message = "Try again later." respond_to do |format| format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end def sign_in(identity) redirect_to_session_magic_link identity.send_magic_link end def sign_up signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) magic_link = signup.create_identity redirect_to_session_magic_link magic_link else respond_to do |format| format.html { redirect_to new_session_path, alert: "Something went wrong" } format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } end end end end ================================================ FILE: app/controllers/signups/completions_controller.rb ================================================ class Signups::CompletionsController < ApplicationController wrap_parameters :signup, include: %i[ full_name ] layout "public" disallow_account_scope def new @signup = Signup.new(identity: Current.identity) end def create @signup = Signup.new(signup_params) if @signup.complete welcome_to_account else invalid_signup end end private def signup_params params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity) end def welcome_to_account respond_to do |format| format.html do flash[:welcome_letter] = true redirect_to landing_url(script_name: @signup.account.slug) end format.json { head :created } end end def invalid_signup respond_to do |format| format.html { render :new, status: :unprocessable_entity } format.json { render json: { errors: @signup.errors.full_messages }, status: :unprocessable_entity } end end end ================================================ FILE: app/controllers/signups_controller.rb ================================================ class SignupsController < ApplicationController wrap_parameters :signup, include: %i[ email_address ] disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } before_action :redirect_authenticated_user before_action :enforce_tenant_limit layout "public" def new @signup = Signup.new end def create signup = Signup.new(signup_params) if signup.valid?(:identity_creation) redirect_to_session_magic_link signup.create_identity else head :unprocessable_entity end end private def redirect_authenticated_user redirect_to new_signup_completion_path if authenticated? end def enforce_tenant_limit redirect_to new_session_url unless Account.accepting_signups? end def signup_params params.expect signup: :email_address end end ================================================ FILE: app/controllers/tags_controller.rb ================================================ class TagsController < ApplicationController def index set_page_and_extract_portion_from Current.account.tags.alphabetically end end ================================================ FILE: app/controllers/users/avatars_controller.rb ================================================ class Users::AvatarsController < ApplicationController allow_unauthenticated_access only: :show before_action :set_user before_action :ensure_permission_to_administer_user, only: :destroy def show if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline") elsif stale? @user, cache_control: cache_control render_initials end end def destroy @user.avatar.destroy respond_to do |format| format.html { redirect_to @user } format.json { head :no_content } end end private def set_user @user = Current.account.users.find(params[:user_id]) end def ensure_permission_to_administer_user head :forbidden unless Current.user.can_change?(@user) end def cache_control if @user == Current.user {} else { max_age: 30.minutes, stale_while_revalidate: 1.week } end end def render_initials render formats: :svg end end ================================================ FILE: app/controllers/users/data_exports_controller.rb ================================================ class Users::DataExportsController < ApplicationController before_action :set_user before_action :ensure_current_user before_action :ensure_export_limit_not_exceeded, only: :create before_action :set_export, only: :show CURRENT_EXPORT_LIMIT = 10 def show end def create @user.data_exports.create!(account: Current.account).build_later redirect_to @user, notice: "Export started. You'll receive an email when it's ready." end private def set_user @user = Current.account.users.find(params[:user_id]) end def ensure_current_user head :forbidden unless @user == Current.user end def ensure_export_limit_not_exceeded head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT end def set_export @export = @user.data_exports.completed.find_by(id: params[:id]) end end ================================================ FILE: app/controllers/users/email_addresses/confirmations_controller.rb ================================================ class Users::EmailAddresses::ConfirmationsController < ApplicationController allow_unauthenticated_access before_action :set_user rate_limit to: 5, within: 1.hour, only: :create def show end def create if @user.change_email_address_using_token(token) terminate_session if Current.session start_new_session_for @user.identity redirect_to edit_user_url(script_name: @user.account.slug, id: @user) else render :invalid_token, status: :unprocessable_entity end end private def set_user @user = Current.account.users.active.find(params[:user_id]) end def token params.expect :email_address_token end end ================================================ FILE: app/controllers/users/email_addresses_controller.rb ================================================ class Users::EmailAddressesController < ApplicationController before_action :set_user rate_limit to: 5, within: 1.hour, only: :create def new end def create identity = Identity.find_by_email_address(new_email_address) if identity&.users&.exists?(account: @user.account) flash[:alert] = "You already have a user in this account with that email address" redirect_to new_user_email_address_path(@user) else @user.send_email_address_change_confirmation(new_email_address) end end private def set_user @user = Current.identity.users.find(params[:user_id]) end def new_email_address params.expect :email_address end end ================================================ FILE: app/controllers/users/events_controller.rb ================================================ class Users::EventsController < ApplicationController include FilterScoped before_action :set_user, :set_filter, :set_user_filtering def show @filter = Current.user.filters.new(creator_ids: [ @user.id ]) @day_timeline = Current.user.timeline_for(day_param, filter: @filter) fresh_when @day_timeline end private def set_user @user = Current.account.users.active.find(params[:user_id]) end def day_param if params[:day].present? Time.zone.parse(params[:day]) else Time.current end end end ================================================ FILE: app/controllers/users/joins_controller.rb ================================================ class Users::JoinsController < ApplicationController wrap_parameters :user, include: %i[ name avatar ] layout "public" def new end def create Current.user.update!(user_params) respond_to do |format| format.html { redirect_to landing_path } format.json { head :no_content } end end private def user_params params.expect(user: [ :name, :avatar ]) end end ================================================ FILE: app/controllers/users/push_subscriptions_controller.rb ================================================ class Users::PushSubscriptionsController < ApplicationController wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ] before_action :set_push_subscriptions def index end def create subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) respond_to do |format| format.html { head :no_content } format.json { head :created } end end def destroy @push_subscriptions.destroy_by(id: params[:id]) respond_to do |format| format.html { redirect_to user_push_subscriptions_url } format.json { head :no_content } end end private def set_push_subscriptions @push_subscriptions = Current.user.push_subscriptions end def push_subscription_params params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key) end end ================================================ FILE: app/controllers/users/roles_controller.rb ================================================ class Users::RolesController < ApplicationController wrap_parameters :user, include: %i[ role ] before_action :set_user before_action :ensure_permission_to_administer_user def update @user.update!(role_params) respond_to do |format| format.html { redirect_to account_settings_path } format.json { head :no_content } end end private def set_user @user = Current.account.users.active.find(params[:user_id]) end def ensure_permission_to_administer_user head :forbidden unless Current.user.can_administer?(@user) end def role_params { role: params.require(:user)[:role].presence_in(%w[ member admin ]) || "member" } end end ================================================ FILE: app/controllers/users/verifications_controller.rb ================================================ class Users::VerificationsController < ApplicationController layout "public" def new end def create Current.user.verify redirect_to new_users_join_path end end ================================================ FILE: app/controllers/users_controller.rb ================================================ class UsersController < ApplicationController wrap_parameters :user, include: %i[ name avatar ] before_action :set_user, except: %i[ index ] before_action :ensure_permission_to_change_user, only: %i[ update destroy ] def index set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity) end def show end def edit end def update if @user.update(user_params) respond_to do |format| format.html { redirect_to @user } format.json { head :no_content } end else respond_to do |format| format.html { render :edit, status: :unprocessable_entity } format.json { render json: @user.errors, status: :unprocessable_entity } end end end def destroy @user.deactivate respond_to do |format| format.html { redirect_to account_settings_path } format.json { head :no_content } end end private def set_user @user = Current.account.users.active.find(params[:id]) end def ensure_permission_to_change_user head :forbidden unless Current.user.can_change?(@user) end def user_params params.expect(user: [ :name, :avatar ]) end end ================================================ FILE: app/controllers/webhooks/activations_controller.rb ================================================ class Webhooks::ActivationsController < ApplicationController include BoardScoped before_action :ensure_admin def create @webhook = @board.webhooks.find(params[:webhook_id]) @webhook.activate respond_to do |format| format.html { redirect_to @webhook } format.json { render "webhooks/show", status: :created } end end end ================================================ FILE: app/controllers/webhooks_controller.rb ================================================ class WebhooksController < ApplicationController wrap_parameters :webhook, include: %i[ name url subscribed_actions ] include BoardScoped before_action :ensure_admin before_action :set_webhook, except: %i[ index new create ] def index set_page_and_extract_portion_from @board.webhooks.ordered end def show end def new @webhook = @board.webhooks.new end def create @webhook = @board.webhooks.new(webhook_params) if @webhook.save respond_to do |format| format.html { redirect_to @webhook } format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) } end else respond_to do |format| format.html { render :new, status: :unprocessable_entity } format.json { render json: @webhook.errors, status: :unprocessable_entity } end end end def edit end def update if @webhook.update(webhook_params.except(:url)) respond_to do |format| format.html { redirect_to @webhook } format.json { render :show } end else respond_to do |format| format.html { render :edit, status: :unprocessable_entity } format.json { render json: @webhook.errors, status: :unprocessable_entity } end end end def destroy @webhook.destroy! respond_to do |format| format.html { redirect_to board_webhooks_path } format.json { head :no_content } end end private def set_webhook @webhook = @board.webhooks.find(params[:id]) end def webhook_params params .expect(webhook: [ :name, :url, subscribed_actions: [] ]) .merge(board_id: @board.id) end end ================================================ FILE: app/helpers/accesses_helper.rb ================================================ module AccessesHelper def access_menu_tag(board, **options, &) tag.menu class: [ options[:class], { "toggler--toggled": board.all_access? } ], data: { controller: "filter toggle-class navigable-list", action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", navigable_list_focus_on_selection_value: true, navigable_list_actionable_items_value: true, toggle_class_toggle_class: "toggler--toggled" }, & end def access_toggles_for(users, selected:, disabled: false) render partial: "boards/access_toggle", collection: users, as: :user, locals: { selected: selected, disabled: disabled }, cached: ->(user) { [ user, selected, disabled ] } end def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false) access = board.access_for(user) turbo_frame_tag dom_id(board, :involvement_button) do concat board_watchers_list(board) if show_watchers concat involvement_button(board, access, show_watchers, icon_only) end end def board_watchers_list(board) watchers = board.watchers.with_avatars.load displayed_watchers = watchers.first(8) overflow_count = watchers.size - 8 tag.div(class: "divider divider--fade") do tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") end + tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) + tag.dialog(avatar_tags(watchers), class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) end if overflow_count > 0 end end def involvement_button(board, access, show_watchers, icon_only) label_text = access.access_only? ? "Watch this" : "Stop watching" button_to( board_involvement_path(board), method: :put, params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, aria: { labelledby: dom_id(board, :involvement_label) }, title: (label_text if icon_only), class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end end private def next_involvement(involvement) order = %w[ watching access_only ] order[(order.index(involvement.to_s) + 1) % order.size] end end ================================================ FILE: app/helpers/application_helper.rb ================================================ module ApplicationHelper def page_title_tag account_name = if Current.account && Current.session&.identity&.users&.many? Current.account&.name end tag.title [ @page_title, account_name, "Fizzy" ].compact.join(" | ") end def icon_tag(name, **options) tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end def back_link_to(label, url, action, prefer_referrer: [], **options) data = { controller: "hotkey", action: action } if prefer_referrer.any? data[:turbo_navigation_target] = "referrerBackLink" data[:turbo_navigation_allowed_referrer_paths] = prefer_referrer.join(",") end link_to url, class: "btn btn--back btn--circle-mobile", data: data, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe end end end ================================================ FILE: app/helpers/avatars_helper.rb ================================================ module AvatarsHelper def avatar_background_color(user) user.avatar_background_color end def avatar_tag(user, hidden_for_screen_reader: false, **options) link_to user_path(user), class: class_names("avatar btn btn--circle", options.delete(:class)), data: { turbo_frame: "_top" }, aria: { hidden: hidden_for_screen_reader, label: user.name }, tabindex: hidden_for_screen_reader ? -1 : nil, **options do avatar_image_tag(user) end end def avatar_tags(users, **options) users.collect { avatar_tag(it, **options) }.join.html_safe end def mail_avatar_tag(user, size: 48, **options) if user.avatar.attached? image_tag user_avatar_url(user), alt: user.name, class: "avatar", size: size, **options else tag.span class: "avatar", style: "background-color: #{avatar_background_color(user)};" do user.initials end end end def avatar_preview_tag(user, hidden_for_screen_reader: false, **options) tag.span class: class_names("avatar", options.delete(:class)), aria: { hidden: hidden_for_screen_reader, label: user.name }, tabindex: hidden_for_screen_reader ? -1 : nil do avatar_image_tag(user, **options) end end def avatar_image_tag(user, **options) image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options end end ================================================ FILE: app/helpers/boards_helper.rb ================================================ module BoardsHelper def link_back_to_board(board, prefer_referrer: []) back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", prefer_referrer: end def link_to_edit_board(board) link_to edit_board_path(board), class: "btn btn--circle-mobile", data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end end ================================================ FILE: app/helpers/bridge_helper.rb ================================================ module BridgeHelper def bridge_icon(name) asset_url("#{name}.svg") end def bridged_button_to_board(board) link_to "Go to #{board.name}", board, hidden: true, data: { bridge__buttons_target: "button", bridge_icon_url: bridge_icon("board"), bridge_title: "Go to #{board.name}" } end def bridged_share_url_button(description = nil) tag.button "Share", hidden: true, data: { controller: "bridge--share", action: "bridge--share#shareUrl", bridge__overflow_menu_target: "item", bridge_title: "Share", bridge_share_description: description } end def bridge_share_card_description(card) date_added = card.created_at.strftime("%b %e") date_updated = card.last_active_at.strftime("%b %e") author = card.creator.familiar_name assignees = card.assignees.any? ? "assigned to #{card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence}" : "not assigned" "Added #{date_added} by #{author} and #{assignees}. Updated #{date_updated}" end def bridge_share_board_description(board) count_open = board.cards.active.count count_in_stream = board.cards.awaiting_triage.count "#{count_open} open cards, #{count_in_stream} in MAYBE?" end end ================================================ FILE: app/helpers/cards_helper.rb ================================================ module CardsHelper def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block) classes = [ options.delete(:class), ("golden-effect" if card.golden?), ("card--postponed" if card.postponed?), ("card--active" if card.active?) ].compact.join(" ") data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed? tag.article \ id: id, style: "--card-color: #{card.color}; view-transition-name: #{id}", class: classes, data: data, **options, &block end def card_title_tag(card) title = [ card.title, "added by #{card.creator.name}", "in #{card.board.name}" ] title << "assigned to #{card.assignees.map(&:name).to_sentence}" if card.assignees.any? 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)) + tag.meta(property: "og:image", content: card.image.attached? ? "#{request.base_url}#{url_for(card.image)}" : "#{request.base_url}/opengraph.png") + tag.meta(property: "og:url", content: card_url(card)) end def button_to_remove_card_image(card) button_to(card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip", action: "dialog#close" }) do icon_tag("trash") + tag.span("Remove background image", class: "for-screen-reader") end end end ================================================ FILE: app/helpers/clipboard_helper.rb ================================================ module ClipboardHelper def button_to_copy_to_clipboard(url, &) tag.button class: "btn", data: { controller: "copy-to-clipboard tooltip", action: "copy-to-clipboard#copy", copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url }, & end end ================================================ FILE: app/helpers/columns_helper.rb ================================================ module ColumnsHelper def button_to_set_column(card, column) button_to \ tag.span(column.name, class: "overflow-ellipsis"), card_triage_path(card, column_id: column), method: :post, class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ], disabled: column == card.column && card.open?, style: "--column-color: #{column.color}", form_class: "flex gap-half", data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil } end def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block) classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed, "is-expanded": !collapsed) hotkeys_disabled = data[:card_hotkeys_disabled] data = { drag_and_drop_target: "container", navigable_list_target: "item", column_name: name, drag_and_drop_url: drop_url, drag_and_drop_css_variable_name: "--card-color", drag_and_drop_css_variable_value: card_color }.merge(data) data[:action] = token_list( "turbo:before-morph-attribute->collapsible-columns#preventToggle", "focus->navigable-list#select", data.delete(:action) ) tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do tag.div(class: "cards__transition-container", data: { controller: "navigable-list css-variable-counter", css_variable_counter_property_name_value: "--card-count", navigable_list_supports_horizontal_navigation_value: "false", navigable_list_prevent_handled_keys_value: "true", navigable_list_auto_select_value: "false", navigable_list_actionable_items_value: "true", navigable_list_only_act_on_focused_items_value: "true", card_hotkeys_disabled: hotkeys_disabled, action: "keydown->navigable-list#navigate" }, &block) end end def column_frame_tag(id, src: nil, data: {}, **options, &block) data = data.with_defaults \ drag_and_drop_refresh: true, controller: "frame", action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload" options[:refresh] = :morph if src.present? turbo_frame_tag(id, src: src, data: data, **options, &block) end end ================================================ FILE: app/helpers/comments_helper.rb ================================================ module CommentsHelper def new_comment_placeholder(card) if card.creator == Current.user && card.comments.empty? "Next, add some notes, context, pictures, or video about this…" else "Type your comment…" end end end ================================================ FILE: app/helpers/emoji_helper.rb ================================================ module EmojiHelper REACTIONS = { "👏" => "Clapping", "👍" => "Thumbs up", "🙌" => "Hands raised in celebration", "💪" => "Flexed bicep", "🤘" => "Sign of the horns", "✊" => "Raised fist", "✨" => "Sparkles", "❤️" => "Red heart", "💯" => "100 points", "🎉" => "Party popper", "🤩" => "Face with starry eyes", "🥳" => "Partying face", "😊" => "Smiling face with flush cheeks", "😀" => "Grinning face", "😂" => "Face with tears of joy", "😅" => "Grinning face with sweat drop", "😎" => "Smiling face with sunglasses", "😉" => "Winking face", "😜" => "Winking face with stuck out tongue", "😬" => "Grimacing face", "😮" => "Surprised face with open mouth", "😳" => "Flushed face", "🤔" => "Thinking face", "😒" => "Unamused face", "😢" => "Crying face", "😭" => "Loudly crying face", "😱" => "Face screaming in fear", "👀" => "Eyes", "🙏" => "Hands pressed together", "💩" => "Pile of poop", "👎" => "Thumbs down", "✌️" => "Peace", "👈" => "Finger pointing left", "👆" => "Finger pointing Up", "✋" => "Raised hand", "👋" => "Waving hand", "☀️" => "Sun", "🌙" => "Moon", "💥" => "Collision", "🔥" => "Fire", "🎂" => "Birthday cake", "🍴" => "Fork and knife", "💰" => "Money bag", "🥇" => "Gold medal", "🚨" => "Red flashing light", "💡" => "Light bulb", "🛠" => "Hammer and wrench", "📈" => "Chart with upward trend", "✅" => "Check mark", "📢" => "Public address loudspeaker" } end ================================================ FILE: app/helpers/entropy_helper.rb ================================================ module EntropyHelper def entropy_bubble_options_for(card) { daysBeforeReminder: card.entropy.days_before_reminder, closesAt: card.entropy.auto_clean_at.iso8601, action: "Closes" } end def stalled_bubble_options_for(card) if card.last_activity_spike_at { stalledAfterDays: card.entropy.days_before_reminder, lastActivitySpikeAt: card.last_activity_spike_at.iso8601, updatedAt: card.updated_at.iso8601, action: "Stalled" } end end end ================================================ FILE: app/helpers/events_helper.rb ================================================ module EventsHelper def event_action_icon(event) case event.action when "card_assigned" "assigned" when "card_unassigned" "minus" when "comment_created" "comment" when "card_title_changed" "rename" when "card_board_changed", "card_triaged", "card_postponed", "card_auto_postponed" "move" else "person" end end def events_at_hour_container(column, hour, &block) tag.div class: "events__time-block", style: "grid-area: #{25 - hour}/#{column.index}", &block end end ================================================ FILE: app/helpers/excerpt_helper.rb ================================================ module ExcerptHelper def format_excerpt(content, length: 200) return "" if content.blank? text = content.respond_to?(:to_plain_text) ? content.to_plain_text : content.to_s text = text.gsub(/^>\s*(.*)$/m, '> \1') text = text.gsub(/^\s*[-+]\s*(.*)$/m, '• \1') text = text.gsub(/^\d+\.\s*(.*)$/m) { |m| m } text = text.gsub(/\s+/, " ").strip text.truncate(length) end end ================================================ FILE: app/helpers/filters_helper.rb ================================================ module FiltersHelper def filter_chip_tag(text, params) link_to cards_path(params), class: "btn txt-x-small btn--remove fill-selected flex-inline" do concat tag.span(text) concat icon_tag("close") end end def filter_hidden_field_tag(key, value) name = params[key].is_a?(Array) ? "#{key}[]" : key hidden_field_tag name, value, id: nil end def filter_selected_boards_title(user_filtering) 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, 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"), 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) end end def filter_dialog(label, &block) tag.dialog class: "margin-block-start-half popup panel flex-column align-start gap-half fill-white shadow txt-small", data: { action: "turbo:before-cache@document->dialog#close keydown->navigable-list#navigate filter:changed->navigable-list#reset toggle->filter#filter", aria: { label: label, aria_description: label }, controller: "navigable-list", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, navigable_list_actionable_items_value: true }, &block end def filter_title(title) tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" } end def collapsible_nav_section(title, **properties, &block) tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do concat icon_tag "caret-down" concat title end) concat(tag.ul(class: "popup__list") do capture(&block) end) end end def filter_hotkey_link(title, path, key, icon) link_to path, class: "popup__item btn borderless", id: "filter-hotkey-#{key}", role: "listitem", data: { filter_target: "item", navigable_list_target: "item", controller: "hotkey", action: "keydown.#{key}@document->hotkey#click keydown.shift+#{key}@document->hotkey#click" } do concat icon_tag(icon) concat tag.span(title.html_safe) concat tag.kbd(key) end end def sorted_by_label(sort_value) case sort_value when "newest" "Newest to oldest" when "oldest" "Oldest to newest" when "latest" "Recently updated" else sort_value.humanize end end end ================================================ FILE: app/helpers/forms_helper.rb ================================================ module FormsHelper def auto_submit_form_with(**attributes, &) data = attributes.delete(:data) || {} data[:controller] = "auto-submit #{data[:controller]}".strip if block_given? form_with **attributes, data: data, & else form_with(**attributes, data: data) { } end end def bridged_form_with(**attributes, &) data = attributes.delete(:data) || {} controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip actions = [ data[:action], "turbo:submit-start->bridge--form#submitStart", "turbo:submit-end->bridge--form#submitEnd" ].compact.join(" ").strip data[:controller] = controllers data[:action] = actions if block_given? form_with **attributes, data: data, & else form_with(**attributes, data: data) { } end end end ================================================ FILE: app/helpers/hotkeys_helper.rb ================================================ module HotkeysHelper # Pass in an array of chorded keys, e.g. ["ctrl", "shift", "J"] def hotkey_label(hotkey) hotkey.map do |key| if key == "ctrl" && platform.mac? "⌘" elsif key == "enter" platform.mac? ? "return" : "enter" else key end.capitalize end.join("+").gsub(/⌘\+/, "⌘") end end ================================================ FILE: app/helpers/html_helper.rb ================================================ module HtmlHelper def format_html(html) Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe end def card_html_title(card) return card.title if card.title.blank? ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '\1').html_safe end end ================================================ FILE: app/helpers/login_helper.rb ================================================ module LoginHelper def login_url main_app.new_session_path(script_name: nil) end def logout_url main_app.new_session_path end def redirect_to_login_url redirect_to login_url, allow_other_host: true end def redirect_to_logout_url redirect_to logout_url, allow_other_host: true end end ================================================ FILE: app/helpers/messages_helper.rb ================================================ module MessagesHelper def messages_tag(card, &) turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", role: "group", aria: { label: "Messages" }, data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, & end end ================================================ FILE: app/helpers/my/menu_helper.rb ================================================ module My::MenuHelper def jump_field_tag text_field_tag :search, nil, type: "search", role: "combobox", placeholder: "Type to jump to a board, person, place, or tag…", class: "input input--transparent txt-small", autofocus: true, autocorrect: "off", autocomplete: "off", aria: { activedescendant: "" }, data: { "1p-ignore": "true", dialog_target: "focusMouse", filter_target: "input", nav_section_expander_target: "input", navigable_list_target: "input", action: "input->filter#filter" } end def my_menu_board_item(board) my_menu_item("board", board) do link_to(tag.span(board.name, class: "overflow-ellipsis"), board, class: "popup__btn btn") end end def my_menu_tag_item(the_tag) my_menu_item("tag", tag) do link_to(tag.span(class: "overflow-ellipsis") do tag.span("##{the_tag.title}", class: "visually-hidden") + the_tag.title end, cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn", title: "##{the_tag.title}") end end def my_menu_user_item(user) my_menu_item("person", user) do link_to(tag.span(user.name, class: "overflow-ellipsis"), user, class: "popup__btn btn") end end def my_menu_filter_item(filter) my_menu_item("bookmark", filter) do link_to(cards_path(filter_id: filter.id), class: "popup__btn btn") do tag.div(class: "txt-tight-lines min-width txt-small overflow-ellipsis") do tag.div(tag.strong(filter.boards_label)) + tag.div(filter.summary, class: "txt-capitalize") end end end end def my_menu_item(item, record) tag.li(class: "popup__item", data: { filter_target: "item", navigable_list_target: "item", id: "filter-#{item}-#{record.id}" }) do icon_tag(item, class: "popup__icon") + yield end end end ================================================ FILE: app/helpers/notifications_helper.rb ================================================ module NotificationsHelper def event_notification_title(event) case event_notification_action(event) when "comment_created" then "RE: #{card_notification_title(event.eventable.card)}" else card_notification_title(event.eventable) end end def event_notification_body(event) creator = event.creator.name case event_notification_action(event) when "card_assigned" then "Assigned to #{event.assignees.none? ? "self" : event.assignees.pluck(:name).to_sentence}" 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 def notification_tag(notification, &) tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: { navigable_list_target: "item", card_id: notification.card.id } do link_to(notification, class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ], data: { turbo_frame: "_top", badge_target: "unread", action: "badge#update dialog#close" }, style: { "--card-color:": notification.card.color }, &) end end def notification_toggle_read_button(notification, url:) if notification.read? button_to url, method: :delete, class: "card__notification-unread-indicator btn btn--circle borderless", title: "Mark as unread", data: { action: "form#submit:stop badge#update:stop", form_target: "submit" }, form: { data: { controller: "form" } } do concat(icon_tag("unseen")) end else button_to url, class: "card__notification-unread-indicator btn btn--circle borderless", title: "Mark as read", data: { action: "form#submit:stop badge#update:stop", form_target: "submit" }, form: { data: { controller: "form" } } do concat(icon_tag("remove")) concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1 end end end def notifications_next_page_link(page) unless @page.last? tag.div id: "next_page", data: { controller: "fetch-on-visible", fetch_on_visible_url_value: notifications_path(page: @page.next_param) } end end def bundle_email_frequency_options_for(settings) options_for_select([ [ "Never", "never" ], [ "Every few hours", "every_few_hours" ], [ "Every day", "daily" ], [ "Every week", "weekly" ] ], settings.bundle_email_frequency) end private def event_notification_action(event) if event.action.card_published? && event.eventable.assigned_to?(event.creator) "card_assigned" else event.action end end def comment_notification_body(event) comment = event.eventable comment.body.to_plain_text.truncate(200) end def card_notification_title(card) card.title.presence || "Card #{card.number}" end end ================================================ FILE: app/helpers/pagination_helper.rb ================================================ module PaginationHelper def pagination_frame_tag(namespace, page, data: {}, **attributes, &) turbo_frame_tag pagination_frame_id_for(namespace, page.number), data: { timeline_target: "frame", **data }, role: "presentation", **attributes, & end def link_to_next_page(namespace, page, activate_when_observed: false, label: default_pagination_label(activate_when_observed), data: {}, **attributes) if page.before_last? && !params[:previous] attributes[:class] = class_names(attributes[:class], "btn txt-small center-block center": !activate_when_observed) pagination_link(namespace, page.number + 1, label: label, activate_when_observed: activate_when_observed, data: data, **attributes) end end def pagination_link(namespace, page_number, activate_when_observed: false, label: default_pagination_label(activate_when_observed), url_params: {}, data: {}, **attributes) link_to label, url_for(params.permit!.to_h.merge(page: page_number, **url_params)), "aria-label": "Load page #{page_number}", id: "#{namespace}-pagination-link-#{page_number}", class: class_names(attributes.delete(:class), "pagination-link", { "pagination-link--active-when-observed" => activate_when_observed }), data: { frame: pagination_frame_id_for(namespace, page_number), pagination_target: "paginationLink", action: ("click->pagination#loadPage:prevent" unless activate_when_observed), **data }, **attributes end def pagination_frame_id_for(namespace, page_number) "#{namespace}-pagination-contents-#{page_number}" end def with_manual_pagination(name, page, **properties) pagination_list name, **properties do concat(pagination_frame_tag(name, page) do yield concat link_to_next_page(name, page) end) end end def with_automatic_pagination(name, page, **properties) pagination_list name, paginate_on_scroll: true, **properties do concat(pagination_frame_tag(name, page) do yield concat link_to_next_page(name, page, activate_when_observed: true) end) end end def day_timeline_pagination_frame_tag(day_timeline, &) turbo_frame_tag day_timeline_pagination_frame_id_for(day_timeline.day), data: { timeline_target: "frame" }, role: "presentation", refresh: :morph, & end def day_timeline_pagination_frame_id_for(day) "day-timeline-pagination-contents-#{day.strftime("%Y-%m-%d")}" end def day_timeline_pagination_link(day_timeline, filter) if day_timeline.next_day link_to "Load more…", events_days_path(day: day_timeline.next_day.strftime("%Y-%m-%d"), **filter.as_params), class: "day-timeline-pagination-link", data: { frame: day_timeline_pagination_frame_id_for(day_timeline.next_day), pagination_target: "paginationLink" } end end private def pagination_list(name, tag_element: :div, paginate_on_scroll: false, **properties, &block) classes = properties.delete(:class) properties[:id] ||= "#{name}-pagination-list" tag.public_send tag_element, class: token_list(name, "display-contents", classes), data: { controller: "pagination", pagination_paginate_on_intersection_value: paginate_on_scroll }, **properties, &block end def default_pagination_label(activate_when_observed) "Load more…" end end ================================================ FILE: app/helpers/qr_codes_helper.rb ================================================ module QrCodesHelper def qr_code_image(url) qr_code_link = QrCodeLink.new(url) image_tag qr_code_path(qr_code_link.signed), class: "qr-code center", alt: "QR Code" end end ================================================ FILE: app/helpers/reactions_helper.rb ================================================ module ReactionsHelper def reaction_path_prefix_for(reactable) case reactable when Card then [ reactable ] when Comment then [ reactable.card, reactable ] else raise ArgumentError, "Unknown reactable type: #{reactable.class}" end end end ================================================ FILE: app/helpers/rich_text_helper.rb ================================================ module RichTextHelper def mentions_prompt(board) content_tag "lexxy-prompt", "", trigger: "@", src: prompts_board_users_path(board), name: "mention" end def global_mentions_prompt content_tag "lexxy-prompt", "", trigger: "@", src: prompts_users_path, name: "mention" end def tags_prompt content_tag "lexxy-prompt", "", trigger: "#", src: prompts_tags_path, name: "tag" end def cards_prompt content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true end def general_prompts(board) safe_join([ mentions_prompt(board), cards_prompt ]) end end ================================================ FILE: app/helpers/tenanting_helper.rb ================================================ module TenantingHelper def tenanted_action_cable_meta_tag tag "meta", name: "action-cable-url", content: "#{request.script_name}#{ActionCable.server.config.mount_path}" end end ================================================ FILE: app/helpers/time_helper.rb ================================================ module TimeHelper def local_datetime_tag(datetime, style: :time, **attributes) # Render empty space to ensure it takes height until the local time is loaded via JS tag.time " ".html_safe, **attributes, datetime: datetime.to_i, data: { local_time_target: style, action: "turbo:morph-element->local-time#refreshTarget" } end end ================================================ FILE: app/helpers/users_helper.rb ================================================ module UsersHelper def role_display_name(user) case user.role when "admin" then "Administrator" else user.role.titleize end end end ================================================ FILE: app/helpers/webhooks_helper.rb ================================================ module WebhooksHelper ACTION_LABELS = { card_published: "Card added", card_title_changed: "Card title changed", card_board_changed: "Card board changed", comment_created: "Comment added", card_assigned: "Card assigned", card_unassigned: "Card unassigned", card_triaged: "Card column changed", card_closed: "Card moved to “Done”", card_reopened: "Card reopened", card_postponed: "Card moved to “Not Now”", card_auto_postponed: "Card moved to “Not Now” due to inactivity", card_sent_back_to_triage: "Card moved back to “Maybe?”" }.with_indifferent_access.freeze def webhook_action_options(actions = Webhook::PERMITTED_ACTIONS) ACTION_LABELS.select { |key, _| actions.include?(key.to_s) } end def webhook_action_label(action) ACTION_LABELS[action] || action.to_s.humanize end def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), class: [ "btn btn--circle-mobile", { "btn--reversed": board.webhooks.any? } ], data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end end end ================================================ FILE: app/javascript/application.js ================================================ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" import "@hotwired/hotwire-native-bridge" import "initializers" import "controllers" import "lexxy" import "@rails/actiontext" import "lib/action_pack/passkey" ================================================ FILE: app/javascript/controllers/application.js ================================================ import { Application } from "@hotwired/stimulus" const application = Application.start() // Configure Stimulus development experience application.debug = false window.Stimulus = application export { application } ================================================ FILE: app/javascript/controllers/assignment_limit_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { limit: Number, count: Number } static targets = ["unassigned", "limitMessage"] connect() { this.updateState() } countValueChanged() { this.updateState() } updateState() { const atLimit = this.countValue >= this.limitValue this.unassignedTargets.forEach(el => { el.hidden = atLimit }) if (this.hasLimitMessageTarget) { this.limitMessageTarget.hidden = !atLimit } } } ================================================ FILE: app/javascript/controllers/auto_click_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.click() } } ================================================ FILE: app/javascript/controllers/auto_save_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { submitForm } from "helpers/form_helpers" const AUTOSAVE_INTERVAL = 3000 export default class extends Controller { #timer // Lifecycle disconnect() { this.submit() } // Actions async submit() { if (this.#dirty) { await this.#save() } } change(event) { if (event.target.form === this.element && !this.#dirty) { this.#scheduleSave() } } // Private #scheduleSave() { this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL) } async #save() { this.#resetTimer() await submitForm(this.element) } #resetTimer() { clearTimeout(this.#timer) this.#timer = null } get #dirty() { return !!this.#timer } } ================================================ FILE: app/javascript/controllers/auto_submit_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.addEventListener("turbo:submit-end", this.#handleSubmitEnd.bind(this), { once: true }) this.submit() } submit() { this.#markAsBusy() this.#disableSubmit() this.element.requestSubmit() } #handleSubmitEnd(event) { if (event.detail.success) { this.element.remove() } else { this.#clearBusy() this.#enableSubmit() } } #markAsBusy() { this.element.setAttribute("aria-busy", "true") } #clearBusy() { this.element.setAttribute("aria-busy", "false") } #disableSubmit() { this.#submitElements().forEach(element => element.disabled = true) } #enableSubmit() { this.#submitElements().forEach(element => element.disabled = false) } #submitElements() { return this.element.querySelectorAll("input[type=submit],button") } } ================================================ FILE: app/javascript/controllers/autoresize_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["textarea", "wrapper"] connect() { this.resize() } resize() { this.wrapperTarget.setAttribute("data-autoresize-clone-value", this.textareaTarget.value) } } ================================================ FILE: app/javascript/controllers/badge_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "unread" ] static classes = [ "unread" ] connect() { onNextEventLoopTick(() => this.update()) } update() { onNextEventLoopTick(() => { if (this.#available) { const unreadCount = this.#unreadCount if (unreadCount > 0) { navigator.setAppBadge(unreadCount) } else { navigator.clearAppBadge() } } }) } clear() { onNextEventLoopTick(() => { if (this.#available) { navigator.clearAppBadge() } }) } get #unreadCount() { return this.unreadTargets.filter(unreadTarget => unreadTarget.classList.contains(this.unreadClass)).length } get #available() { return "setAppBadge" in navigator } } ================================================ FILE: app/javascript/controllers/bar_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" import { nextFrame } from "helpers/timing_helpers"; export default class extends Controller { static targets = [ "turboFrame", "search", "searchInput", "form", "buttonsContainer" ] static outlets = [ "dialog" ] static values = { searchUrl: String, } dialogOutletConnected(outlet, element) { outlet.close() this.#clearTurboFrame() } reset() { this.dialogOutlet.close() this.#clearTurboFrame() this.#showItem(this.buttonsContainerTarget) this.#hideItem(this.searchTarget) } showModalAndSubmit(event) { this.showModal() this.formTarget.requestSubmit() this.#restoreFocusAfterTurboFrameLoads() } showModal() { this.dialogOutlet.open() } search(event) { this.#showItem(this.searchTarget) this.#hideItem(this.buttonsContainerTarget) if (this.searchInputTarget.value.trim()) { this.showModalAndSubmit() } else { this.#loadTurboFrame() } } #restoreFocusAfterTurboFrameLoads() { this.turboFrameTarget.addEventListener("turbo:frame-load", () => { this.searchInputTarget.focus() }, { once: true }) } #loadTurboFrame() { this.turboFrameTarget.src = this.searchUrlValue } #clearTurboFrame() { this.turboFrameTarget.removeAttribute("src") this.turboFrameTarget.innerHtml = "" } async #showItem(element) { element.removeAttribute("hidden") const autofocusElement = element.querySelector("[autofocus]") autofocusElement?.focus() await nextFrame() autofocusElement?.select() } #hideItem(element) { element.setAttribute("hidden", "hidden") } } ================================================ FILE: app/javascript/controllers/beacon_controller.js ================================================ import { post } from "@rails/request.js" import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { url: String } connect() { this.#sendBeacon() this.onVisibilityChange = this.#sendBeacon.bind(this); document.addEventListener("visibilitychange", this.onVisibilityChange) } disconnect() { this.#sendBeacon() document.removeEventListener("visibilitychange", this.onVisibilityChange) } #sendBeacon() { if (!document.hidden) { post(this.urlValue, { responseKind: "turbo-stream" }) } } } ================================================ FILE: app/javascript/controllers/boards_form_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { nextEventLoopTick } from "helpers/timing_helpers"; export default class extends Controller { static targets = ["meCheckbox"] static values = { selfRemovalPromptMessage: { type: String, default: "Are you sure?" } } async submitWithWarning(event) { if (this.hasMeCheckboxTarget && !this.meCheckboxTarget.checked && !this.confirmed) { event.detail.formSubmission.stop() const message = this.selfRemovalPromptMessageValue if (confirm(message)) { await nextEventLoopTick() this.confirmed = true this.element.requestSubmit() } } } } ================================================ FILE: app/javascript/controllers/bridge/buttons_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" import { BridgeElement } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "buttons" static targets = [ "button" ] connect() { super.connect() if (!this.beforeUnloadHandler) { this.beforeUnloadHandler = this.handleBeforeUnload.bind(this) } window.addEventListener("beforeunload", this.beforeUnloadHandler) } disconnect() { super.disconnect() if (this.beforeUnloadHandler) { window.removeEventListener("beforeunload", this.beforeUnloadHandler) } this.notifyBridgeOfDisconnect() } buttonTargetConnected() { this.notifyBridgeOfConnect() } buttonTargetDisconnected() { if (!this.#isControllerTearingDown()) { this.notifyBridgeOfConnect() } } notifyBridgeOfConnect() { const buttons = this.#enabledButtonTargets .map((target, index) => { const element = new BridgeElement(target) return { ...element.getButton(), index } }) this.send("connect", { buttons }, message => { this.#clickButton(message) }) } notifyBridgeOfDisconnect() { this.send("disconnect") } handleBeforeUnload() { this.notifyBridgeOfDisconnect() } #clickButton(message) { const selectedIndex = message.data.selectedIndex this.#enabledButtonTargets[selectedIndex].click() } get #enabledButtonTargets() { return this.buttonTargets .filter(target => !target.closest("[data-bridge-disabled]")) } #isControllerTearingDown() { return !document.body.contains(this.element) } } ================================================ FILE: app/javascript/controllers/bridge/form_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" import { BridgeElement } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "form" static targets = [ "submit", "cancel" ] static values = { submitTitle: String } connect() { super.connect() if (!this.beforeUnloadHandler) { this.beforeUnloadHandler = this.handleBeforeUnload.bind(this) } window.addEventListener("beforeunload", this.beforeUnloadHandler) } disconnect() { super.disconnect() if (this.beforeUnloadHandler) { window.removeEventListener("beforeunload", this.beforeUnloadHandler) } } submitTargetConnected() { this.notifyBridgeOfConnect() this.#observeSubmitTarget() } submitTargetDisconnected() { this.notifyBridgeOfDisconnect() this.submitObserver?.disconnect() } notifyBridgeOfConnect() { const submitElement = new BridgeElement(this.submitTarget) const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null const submitButton = { title: submitElement.title } const cancelButton = cancelElement ? { title: cancelElement.title } : null this.send("connect", { submitButton, cancelButton }, message => this.receive(message)) } receive(message) { switch (message.event) { case "submit": this.submitTarget.click() break case "cancel": this.cancelTarget.click() break } } notifyBridgeOfDisconnect() { this.send("disconnect") } submitStart() { this.send("submitStart") } submitEnd() { this.send("submitEnd") } handleBeforeUnload() { this.notifyBridgeOfDisconnect() } #observeSubmitTarget() { this.submitObserver = new MutationObserver(() => { this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") }) this.submitObserver.observe(this.submitTarget, { attributes: true, attributeFilter: [ "disabled" ] }) } } ================================================ FILE: app/javascript/controllers/bridge/insets_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" // Bridge component to control custom safe-area insets from native apps. // Sets CSS variables --injected-safe-inset-(top|right|bottom|left). export default class extends BridgeComponent { static component = "insets" connect() { super.connect() this.notifyBridgeOfConnect() } disconnect() { super.disconnect() this.send("disconnect") } notifyBridgeOfConnect() { this.send("connect", {}, message => { this.#setInsets(message.data) }) } #setInsets({ top, right, bottom, left }) { const root = document.documentElement.style root.setProperty("--injected-safe-inset-top", `${top}px`) root.setProperty("--injected-safe-inset-right", `${right}px`) root.setProperty("--injected-safe-inset-bottom", `${bottom}px`) root.setProperty("--injected-safe-inset-left", `${left}px`) } } ================================================ FILE: app/javascript/controllers/bridge/overflow_menu_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" import { BridgeElement } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "overflow-menu" static targets = [ "item" ] itemTargetConnected() { this.notifyBridgeOfConnect() } itemTargetDisconnected() { if (!this.#isControllerTearingDown) { this.notifyBridgeOfConnect() } } notifyBridgeOfConnect() { const items = this.#enabledItemTargets .map((target, index) => { const element = new BridgeElement(target) return { title: element.title, index } }) this.send("connect", { items }, message => { this.#clickItem(message) }) } #clickItem(message) { const selectedIndex = message.data.selectedIndex this.#enabledItemTargets[selectedIndex].click() } get #enabledItemTargets() { return this.itemTargets .filter(target => !target.closest("[data-bridge-disabled]")) } #isControllerTearingDown() { return !document.body.contains(this.element) } } ================================================ FILE: app/javascript/controllers/bridge/share_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "share" shareUrl() { const description = this.bridgeElement.bridgeAttribute("share-description") this.send("shareUrl", { title: document.title, url: window.location.href, description: description }) } } ================================================ FILE: app/javascript/controllers/bridge/stamp_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "stamp" static values = { scopeSelector: { type: String, default: "body" } } connect() { super.connect() if (this.element.closest(this.scopeSelectorValue)) { this.notifyBridgeOfConnect() this.#observeStamp() } } disconnect() { super.disconnect() this.notifyBridgeOfDisconnect() this.stampObserver?.disconnect() } notifyBridgeOfConnect() { const bridgeElement = this.bridgeElement this.send("connect", { title: bridgeElement.title, description: bridgeElement.bridgeAttribute("description") }) } notifyBridgeOfDisconnect() { this.send("disconnect") } #observeStamp() { this.stampObserver = new MutationObserver(() => { this.notifyBridgeOfConnect() }) this.stampObserver.observe(this.element, { attributes: true }) } } ================================================ FILE: app/javascript/controllers/bridge/text_size_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" export default class extends BridgeComponent { static component = "text-size" connect() { super.connect() this.notifyBridgeOfConnect() } disconnect() { super.disconnect() this.send("disconnect") } notifyBridgeOfConnect() { this.send("connect", {}, message => { this.#setTextSize(message.data) }) } #setTextSize(data) { document.documentElement.dataset.textSize = data.textSize } } ================================================ FILE: app/javascript/controllers/bridge/title_controller.js ================================================ import { BridgeComponent } from "@hotwired/hotwire-native-bridge" import { viewport } from "helpers/bridge/viewport_helpers" import { nextFrame } from "helpers/timing_helpers" export default class extends BridgeComponent { static component = "title" static targets = [ "header" ] static values = { title: String } async connect() { super.connect() await nextFrame() this.#startObserver() window.addEventListener("resize", this.#windowResized) } disconnect() { super.disconnect() this.#stopObserver() window.removeEventListener("resize", this.#windowResized) } notifyBridgeOfVisibilityChange(visible) { this.send("visibility", { title: this.#title, elementVisible: visible }) } // Intersection Observer #startObserver() { if (!this.hasHeaderTarget) return this.observer = new IntersectionObserver(([ entry ]) => this.notifyBridgeOfVisibilityChange(entry.isIntersecting), { rootMargin: `-${this.#topOffset}px 0px 0px 0px` } ) this.observer.observe(this.headerTarget) this.previousTopOffset = this.#topOffset } #stopObserver() { this.observer?.disconnect() } #updateObserverIfNeeded() { if (this.#topOffset === this.previousTopOffset) return this.#stopObserver() this.#startObserver() } #windowResized = () => { this.#updateObserverIfNeeded() } get #title() { return this.titleValue ? this.titleValue : document.title } get #topOffset() { return viewport.top } } ================================================ FILE: app/javascript/controllers/bubble_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { signedDifferenceInDays } from "helpers/date_helpers" const REFRESH_INTERVAL = 3_600_000 // 1 hour (in milliseconds) export default class extends Controller { static targets = [ "entropy", "stalled", "top", "center", "bottom" ] static values = { entropy: Object, stalled: Object } #timer connect() { this.#timer = setInterval(this.update.bind(this), REFRESH_INTERVAL) this.update() } disconnect() { clearInterval(this.#timer) } update() { if (this.#hasEntropy) { this.#showEntropy() } else if (this.#isStalled) { this.#showStalled() } else { this.#hide() } } get #hasEntropy() { return this.#entropyCleanupInDays < this.entropyValue.daysBeforeReminder } get #entropyCleanupInDays() { return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt)) } #showEntropy() { this.#render({ top: this.#entropyCleanupInDays < 1 ? this.entropyValue.action : `${this.entropyValue.action} in`, center: this.#entropyCleanupInDays < 1 ? "!" : this.#entropyCleanupInDays, bottom: this.#entropyCleanupInDays < 1 ? "Today" : (this.#entropyCleanupInDays === 1 ? "day" : "days"), }) } #render({ top, center, bottom }) { this.topTarget.innerHTML = top this.centerTarget.innerHTML = center this.bottomTarget.innerHTML = bottom this.#show() } // Keep in sync with Card::Stallable#stalled? in app/models/card/stallable.rb get #isStalled() { return this.stalledValue.lastActivitySpikeAt && signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays && signedDifferenceInDays(new Date(this.stalledValue.updatedAt), new Date()) > this.stalledValue.stalledAfterDays } #showStalled() { this.#render({ top: "Stalled for", center: signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()), bottom: "days" }) } #hide() { this.element.toggleAttribute("hidden", true) } #show() { this.element.removeAttribute("hidden") } } ================================================ FILE: app/javascript/controllers/card_hotkeys_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" export default class extends Controller { static outlets = [ "navigable-list" ] connect() { this.morphCompletePromise = null this.morphCompleteResolver = null } handleKeydown(event) { if (this.#shouldIgnore(event) || this.#hasModifier(event)) return const handler = this.#keyHandlers[event.key.toLowerCase()] if (handler) { handler.call(this, event) } } // Called when turbo:morph completes - resolves our waiting promise handleMorphComplete() { if (this.morphCompleteResolver) { this.morphCompleteResolver() this.morphCompleteResolver = null this.morphCompletePromise = null } } // Private #shouldIgnore(event) { const target = event.target return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || target.closest("input, textarea, [contenteditable], lexxy-editor") } #hasModifier(event) { return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey } get #selectedCard() { // Find the navigable-list that currently has focus const focusedList = this.navigableListOutlets.find(list => list.hasFocus) if (!focusedList) return null const currentItem = focusedList.currentItem if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) { return { card: currentItem, controller: focusedList } } return null } async #postponeCard(event) { const selection = this.#selectedCard if (!selection) return const url = selection.card.dataset.cardNotNowUrl if (url) { event.preventDefault() await this.#performCardAction(url, selection) } } async #closeCard(event) { const selection = this.#selectedCard if (!selection) return const url = selection.card.dataset.cardClosureUrl if (url) { event.preventDefault() await this.#performCardAction(url, selection) } } async #assignToMe(event) { const selection = this.#selectedCard if (!selection) return const url = selection.card.dataset.cardAssignToSelfUrl if (url) { event.preventDefault() await post(url, { responseKind: "turbo-stream" }) } } async #performCardAction(url, selection) { const { controller } = selection const visibleItems = controller.visibleItems const currentIndex = visibleItems.indexOf(selection.card) const wasLastItem = currentIndex === visibleItems.length - 1 // Set up promise to wait for morph completion this.morphCompletePromise = new Promise(resolve => { this.morphCompleteResolver = resolve }) await post(url, { responseKind: "turbo-stream" }) // Wait for Turbo Stream morph to complete await Promise.race([ this.morphCompletePromise, new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout ]) // Select the next card (or previous if it was the last) const newVisibleItems = controller.visibleItems if (newVisibleItems.length === 0) { controller.clearSelection() return } if (wasLastItem) { controller.selectLast() } else { const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1) if (newVisibleItems[nextIndex]) { await controller.selectItem(newVisibleItems[nextIndex]) } } } #hotkeysDisabled(navigableList) { return navigableList?.element.dataset.cardHotkeysDisabled === "true" } #keyHandlers = { "["(event) { this.#postponeCard(event) }, "]"(event) { this.#closeCard(event) }, m(event) { this.#assignToMe(event) } } } ================================================ FILE: app/javascript/controllers/clear_offline_cache_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { Turbo } from "@hotwired/turbo-rails" export default class extends Controller { clearCache() { Turbo.offline.clearCache() } } ================================================ FILE: app/javascript/controllers/clicker_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { nextFrame } from "helpers/timing_helpers"; export default class extends Controller { static targets = [ "clickable" ] async click() { await nextFrame() this.#clickable.click() } get #clickable() { return this.hasClickableTarget ? this.clickableTarget : this.element } } ================================================ FILE: app/javascript/controllers/collapsible_columns_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { nextFrame, debounce } from "helpers/timing_helpers"; import { isNative } from "helpers/platform_helpers"; export default class extends Controller { static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ] static targets = [ "column", "button", "title", "maybeColumn" ] static values = { board: String, desktopBreakpoint: { type: String, default: "(min-width: 640px)" } } initialize() { this.restoreState = debounce(this.restoreState.bind(this), 10) } async connect() { this.mediaQuery = window.matchMedia(this.desktopBreakpointValue) this.handlePlatform = this.#handlePlatform.bind(this) this.mediaQuery.addEventListener("change", this.handlePlatform) await this.#restoreColumnsDisablingTransitions() this.#setupIntersectionObserver() } disconnect() { if (this._intersectionObserver) { this._intersectionObserver.disconnect() this._intersectionObserver = null } this.mediaQuery.removeEventListener("change", this.handlePlatform) } toggle({ target }) { const column = target.closest('[data-collapsible-columns-target~="column"]') this.#toggleColumn(column); } preventToggle(event) { if (event.target.hasAttribute("data-collapsible-columns-target") && event.detail.attributeName === "class") { event.preventDefault() } } async restoreState(event) { await nextFrame() await this.#restoreColumnsDisablingTransitions() } focusOnColumn({ target }) { if (this.#isDesktop && this.#isCollapsed(target)) { this.#collapseAllExcept(target) this.#expand({ column: target }) } } frameColumnOnMobile(event) { if (!this.#isDesktop) { event.currentTarget.scrollIntoView({ behavior: "smooth", inline: "center" }) } } async #restoreColumnsDisablingTransitions() { this.#disableTransitions() this.#restoreColumns() this.#handlePlatform() await nextFrame() this.#enableTransitions() } #disableTransitions() { this.element.classList.add(this.noTransitionsClass) } #enableTransitions() { this.element.classList.remove(this.noTransitionsClass) } #toggleColumn(column) { this.#collapseAllExcept(column) if (this.#isCollapsed(column)) { this.#expand({ column }) } else { this.#collapse(column) } } #collapseAllExcept(clickedColumn) { const columns = this.#isDesktop ? this.columnTargets.filter(c => c !== this.maybeColumnTarget) : this.columnTargets columns.forEach(column => { if (column !== clickedColumn) { this.#collapse(column) } }) } #isCollapsed(column) { return column.classList.contains(this.collapsedClass) } #collapse(column) { const key = this.#localStorageKeyFor(column) this.#buttonFor(column)?.setAttribute("aria-expanded", "false") column.classList.remove(this.expandedClass) column.classList.add(this.collapsedClass) localStorage.removeItem(key) } #expand({ column, saveState = true, scrollBehavior = "smooth" }) { this.#buttonFor(column)?.setAttribute("aria-expanded", "true") column.classList.remove(this.collapsedClass) column.classList.add(this.expandedClass) if (saveState) { const key = this.#localStorageKeyFor(column) localStorage.setItem(key, true) } if (window.matchMedia('(max-width: 639px)').matches) { column.scrollIntoView({ behavior: scrollBehavior, inline: "center" }) } } #buttonFor(column) { return this.buttonTargets.find(button => column.contains(button)) } #restoreColumns() { this.columnTargets.forEach(column => { this.#restoreColumn(column) }) } #restoreColumn(column) { const key = this.#localStorageKeyFor(column) if (localStorage.getItem(key)) { this.#collapseAllExcept(column) this.#expand({ column, scrollBehavior: isNative() ? "instant" : "smooth" }) } } #localStorageKeyFor(column) { return `expand-${this.boardValue}-${column.getAttribute("id")}` } #setupIntersectionObserver() { if (typeof IntersectionObserver === "undefined") return if (this._intersectionObserver) this._intersectionObserver.disconnect() this._intersectionObserver = new IntersectionObserver(entries => { entries.forEach(entry => { const title = entry.target const column = title.closest(".cards") if (!column) return const offscreen = entry.intersectionRatio === 0 column.classList.toggle(this.titleNotVisibleClass, offscreen) }) }, { threshold: [0] }) this.titleTargets.forEach(title => this._intersectionObserver.observe(title)) } get #isDesktop() { return this.mediaQuery?.matches } #handlePlatform() { this.#isDesktop ? this.#handleDesktopMode() : this.#handleMobileMode() } async #handleDesktopMode() { this.#expand({ column: this.maybeColumnTarget, saveState: false }) this.#maybeButton.setAttribute("disabled", true) } #handleMobileMode() { this.#maybeButton.removeAttribute("disabled") const expandedColumn = this.columnTargets.find(column => column !== this.maybeColumnTarget && !this.#isCollapsed(column)) if (expandedColumn) { this.#collapseAllExcept(expandedColumn) } else { this.#collapseAllExcept(this.maybeColumnTarget) this.#expand({ column: this.maybeColumnTarget, saveState: false }) } } get #maybeButton() { return this.maybeColumnTarget.querySelector('[data-collapsible-columns-target="button"]') } } ================================================ FILE: app/javascript/controllers/combobox_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { #hiddenField static targets = [ "label", "item", "hiddenFieldTemplate" ] static values = { selectPropertyName: { type: String, default: "aria-checked" }, defaultValue: String, defaultLabel: String } static classes = ["withDefault"] connect() { this.#selectedItem = this.#selectedItem } change(event) { const item = event.target.closest("[role='checkbox']") if (item) { this.#selectedItem = item } } get #selectedLabel() { const selectedValue = this.#selectedItemValue() if (this.hasDefaultLabelValue && (selectedValue === this.defaultValueValue || !selectedValue)) { return this.defaultLabelValue } return this.#selectedItem?.dataset?.comboboxLabel || "" } get #selectedItem() { return this.itemTargets.find(item => item.getAttribute(this.selectPropertyNameValue) === "true") } #selectedItemValue() { return this.#selectedItem?.dataset?.comboboxValue || "" } set #selectedItem(item) { if (!item) return this.#clearSelection() item.setAttribute(this.selectPropertyNameValue, "true") this.labelTarget.textContent = this.#selectedLabel this.hiddenField.value = item.dataset.comboboxValue this.hiddenField.disabled = !item.dataset.comboboxValue this.#updateWithDefaultClass() } #clearSelection() { this.itemTargets.forEach(target => { target.setAttribute(this.selectPropertyNameValue, "false") }) } get hiddenField() { if (!this.#hiddenField) { this.#hiddenField = this.#buildHiddenField() } return this.#hiddenField } #buildHiddenField() { const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children this.element.appendChild(field) return field } #updateWithDefaultClass() { if (this.hasWithDefaultClass && this.hasDefaultValueValue) { const selectedValue = this.#selectedItemValue() const shouldHaveClass = selectedValue === this.defaultValueValue if (shouldHaveClass) { this.element.classList.add(this.withDefaultClass) } else { this.element.classList.remove(this.withDefaultClass) } } } } ================================================ FILE: app/javascript/controllers/copy_to_clipboard_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { content: String } static classes = [ "success" ] async copy(event) { event.preventDefault() this.reset() try { await navigator.clipboard.writeText(this.contentValue) this.element.classList.add(this.successClass) } catch {} } reset() { this.element.classList.remove(this.successClass) this.#forceReflow() } #forceReflow() { this.element.offsetWidth } } ================================================ FILE: app/javascript/controllers/css_variable_counter_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { debounce } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "item", "counter" ] static values = { propertyName: String, maxValue: { type: Number, default: 15 } // should match first geared pagination page size } initialize() { this.#updateCounter = debounce(this.#updateCounter.bind(this), 50) } connect() { if (this.itemTargets.length > 0) { this.#updateCounter() } } itemTargetConnected() { this.#updateCounter() } itemTargetDisconnected() { this.#updateCounter() } #updateCounter = () => { if (!this.hasCounterTarget) return const count = Math.min(this.itemTargets.length, this.maxValueValue) this.counterTarget.style.setProperty(this.propertyNameValue, count) } } ================================================ FILE: app/javascript/controllers/details_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "details" ] close() { this.detailsTarget.removeAttribute("open") } closeOnClickOutside({ target }) { if (!this.element.contains(target)) this.close() } } ================================================ FILE: app/javascript/controllers/dialog_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { static targets = [ "dialog", "focusMouse", "focusTouch" ] static values = { modal: { type: Boolean, default: false }, sizing: { type: Boolean, default: true }, autoOpen: { type: Boolean, default: false }, orient: { type: Boolean, default: true } } connect() { this.dialogTarget.setAttribute("aria-hidden", "true") if (this.autoOpenValue) this.open() } focusTouchTargetConnected() { this.#setupFocus() } open() { const modal = this.modalValue if (modal) { this.dialogTarget.showModal() } else { this.dialogTarget.show() if (this.orientValue) { orient({ target: this.dialogTarget, anchor: this.element }) } } this.loadLazyFrames() this.dialogTarget.setAttribute("aria-hidden", "false") this.dispatch("show") } toggle() { if (this.dialogTarget.open) { this.close() } else { this.open() } } close() { this.dialogTarget.close() this.dialogTarget.setAttribute("aria-hidden", "true") this.dialogTarget.blur() orient({ target: this.dialogTarget, reset: true }) this.dispatch("close") } closeOnClickOutside({ target }) { if (!this.element.contains(target)) this.close() } preventCloseOnMorphing(event) { if (event.detail?.attributeName === "open") { event.preventDefault() event.stopPropagation() } } loadLazyFrames() { Array.from(this.dialogTarget.querySelectorAll("turbo-frame")).forEach(frame => { frame.loading = "eager" }) } captureKey(event) { if (event.key !== "Escape") { event.stopPropagation() } } #setupFocus() { const touch = isTouchDevice() if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch } } ================================================ FILE: app/javascript/controllers/dialog_manager_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.addEventListener("dialog:show", this.handleDialogShow.bind(this)) } disconnect() { this.element.removeEventListener("dialog:show", this.handleDialogShow.bind(this)) } handleDialogShow(event) { this.#dialogControllers.forEach(dialogController => { if (dialogController !== event.target) { const dialog = dialogController.querySelector("dialog") dialog.removeAttribute("open") } }) } get #dialogControllers() { return this.element.querySelectorAll('[data-controller~="dialog"]') } } ================================================ FILE: app/javascript/controllers/drag_and_drop_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" import { nextFrame } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "item", "container" ] static classes = [ "draggedItem", "hoverContainer" ] // Actions async dragStart(event) { event.dataTransfer.effectAllowed = "move" event.dataTransfer.dropEffect = "move" event.dataTransfer.setData("37ui/move", event.target) await nextFrame() this.dragItem = this.#itemContaining(event.target) this.sourceContainer = this.#containerContaining(this.dragItem) this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer) this.dragItem.classList.add(this.draggedItemClass) } dragOver(event) { event.preventDefault() if (!this.dragItem) { return } const container = this.#containerContaining(event.target) this.#clearContainerHoverClasses() if (!container) { return } if (container !== this.sourceContainer) { container.classList.add(this.hoverContainerClass) this.#applyContainerCssVariableToDraggedItem(container) } else { this.#restoreOriginalDraggedItemCssVariable() } } async drop(event) { const targetContainer = this.#containerContaining(event.target) if (!targetContainer || targetContainer === this.sourceContainer) { return } this.wasDropped = true this.#increaseCounter(targetContainer) this.#decreaseCounter(this.sourceContainer) const sourceContainer = this.sourceContainer this.#insertDraggedItem(targetContainer, this.dragItem) await this.#submitDropRequest(this.dragItem, targetContainer) this.#reloadSourceFrame(sourceContainer) } dragEnd() { this.dragItem.classList.remove(this.draggedItemClass) this.#clearContainerHoverClasses() if (!this.wasDropped) { this.#restoreOriginalDraggedItemCssVariable() } this.sourceContainer = null this.dragItem = null this.wasDropped = false this.originalDraggedItemCssVariable = null } #itemContaining(element) { return this.itemTargets.find(item => item.contains(element) || item === element) } #containerContaining(element) { return this.containerTargets.find(container => container.contains(element) || container === element) } #clearContainerHoverClasses() { this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass)) } #applyContainerCssVariableToDraggedItem(container) { const cssVariable = this.#containerCssVariableFor(container) if (cssVariable) { this.dragItem.style.setProperty(cssVariable.name, cssVariable.value) } } #restoreOriginalDraggedItemCssVariable() { if (this.originalDraggedItemCssVariable) { const { name, value } = this.originalDraggedItemCssVariable this.dragItem.style.setProperty(name, value) } } #containerCssVariableFor(container) { const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset if (dragAndDropCssVariableName && dragAndDropCssVariableValue) { return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue } } return null } #increaseCounter(container) { this.#modifyCounter(container, count => count + 1) } #decreaseCounter(container) { this.#modifyCounter(container, count => Math.max(0, count - 1)) } #modifyCounter(container, fn) { const counterElement = container.querySelector("[data-drag-and-drop-counter]") if (counterElement) { const currentValue = counterElement.textContent.trim() if (!/^\d+$/.test(currentValue)) return counterElement.textContent = fn(parseInt(currentValue)) } } #insertDraggedItem(container, item) { const itemContainer = container.querySelector("[data-drag-drop-item-container]") const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]") const firstTopItem = topItems[0] const lastTopItem = topItems[topItems.length - 1] const isTopItem = item.hasAttribute("data-drag-and-drop-top") const referenceItem = isTopItem ? firstTopItem : lastTopItem if (referenceItem) { referenceItem[isTopItem ? "before" : "after"](item) } else { itemContainer.prepend(item) } } async #submitDropRequest(item, container) { const body = new FormData() const id = item.dataset.id const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id) return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } }) } #reloadSourceFrame(sourceContainer) { const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]") if (frame) frame.reload() } } ================================================ FILE: app/javascript/controllers/drag_and_strum_controller.js ================================================ import { Controller } from "@hotwired/stimulus" const INSTRUMENTS = [ [ "/audio/vibes/B3.mp3", "/audio/vibes/C3.mp3", "/audio/vibes/D4.mp3", "/audio/vibes/E3.mp3", "/audio/vibes/Fsharp4.mp3", "/audio/vibes/G3.mp3" ], [ "/audio/banjo/B3.mp3", "/audio/banjo/C3.mp3", "/audio/banjo/D4.mp3", "/audio/banjo/E3.mp3", "/audio/banjo/Fsharp4.mp3", "/audio/banjo/G3.mp3" ], [ "/audio/harpsichord/B3.mp3", "/audio/harpsichord/C3.mp3", "/audio/harpsichord/D4.mp3", "/audio/harpsichord/E3.mp3", "/audio/harpsichord/Fsharp4.mp3", "/audio/harpsichord/G3.mp3" ], [ "/audio/mandolin/B3.mp3", "/audio/mandolin/C3.mp3", "/audio/mandolin/D4.mp3", "/audio/mandolin/E3.mp3", "/audio/mandolin/Fsharp4.mp3", "/audio/mandolin/G3.mp3" ], [ "/audio/piano/B3.mp3", "/audio/piano/C3.mp3", "/audio/piano/D4.mp3", "/audio/piano/E3.mp3", "/audio/piano/Fsharp4.mp3", "/audio/piano/G3.mp3" ], ] export default class extends Controller { static targets = [ "container" ] connect() { this.instrumentIndex = 0 this.preloadedAudioFiles = [] document.addEventListener("keydown", this.handleKeyDown.bind(this)); } disconnect() { document.removeEventListener("keydown", this.handleKeyDown.bind(this)); } handleKeyDown(event) { if (event.shiftKey) { this.instrumentIndex = this.#getInstrumentIndex(event) if (this.instrumentIndex < INSTRUMENTS.length) { this.#preloadAudioFiles(this.instrumentIndex) } } } dragEnter(event) { event.preventDefault() const container = this.#containerContaining(event.target) if (!container) { return } if (container !== this.sourceContainer && event.shiftKey) { this.#playSound() } } #getInstrumentIndex(event) { const number = Number(event.code.replace("Digit", "")) return isNaN(number) ? 0 : number } #preloadAudioFiles(instrumentIndex) { this.preloadedAudioFiles = [] const audioFiles = INSTRUMENTS[instrumentIndex]; if (audioFiles) { this.preloadedAudioFiles = audioFiles.map(file => { const audio = new Audio(file) audio.load() return audio }) } } #containerContaining(element) { return this.containerTargets.find(container => container.contains(element) || container === element) } #playSound() { const randomIndex = Math.floor(Math.random() * this.preloadedAudioFiles.length) const audio = this.preloadedAudioFiles[randomIndex] const audioInstance = new Audio(audio.src) audioInstance.play() } } ================================================ FILE: app/javascript/controllers/element_removal_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { remove() { this.element.remove() } } ================================================ FILE: app/javascript/controllers/expandable_on_native_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { isNative } from "helpers/platform_helpers" export default class extends Controller { static get shouldLoad() { return isNative() } static values = { autoExpandSelector: String } connect() { this.element.open = this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue) } } ================================================ FILE: app/javascript/controllers/fetch_on_visible_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { get } from "@rails/request.js" export default class extends Controller { static values = { url: String } connect() { this.#observe() } #observe() { const observer = new IntersectionObserver((entries) => { const visible = !!entries.find(entry => entry.isIntersecting) if (visible) { this.#fetch() } }) observer.observe(this.element) } #fetch() { get(this.urlValue, { responseKind: "turbo-stream" }) } } ================================================ FILE: app/javascript/controllers/filter_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { debounce } from "helpers/timing_helpers" import { filterMatches } from "helpers/text_helpers" export default class extends Controller { static targets = [ "input", "item" ] initialize() { this.filter = debounce(this.filter.bind(this), 100) } filter() { this.itemTargets.forEach(item => { if (filterMatches(item.textContent, this.inputTarget.value)) { item.removeAttribute("hidden") } else { item.toggleAttribute("hidden", true) } }) this.dispatch("changed") } clearInput() { if (!this.hasInputTarget) return this.inputTarget.value = "" } } ================================================ FILE: app/javascript/controllers/filter_form_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { clearCategory({ params: { name } }) { name.split(",").forEach(name => { this.element.querySelectorAll(`input[name="${name}"]`).forEach(input => { input.checked = false }) }) } } ================================================ FILE: app/javascript/controllers/filter_settings_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { debounce } from "helpers/timing_helpers"; import { post } from "@rails/request.js" export default class extends Controller { static classes = ["filtersSet"] static targets = ["field", "form"] static values = { refreshUrl: String, noFilteringUrl: String, cardsUrl: String } initialize() { this.debouncedToggle = debounce(this.#toggle.bind(this), 50) } connect() { this.#toggle() } change(event) { this.#toggle() this.#refreshSaveToggleButton() } resetIfNoFiltering(event) { if (!this.#hasFiltersSet) { this.#showNoFilteringUrl() event.stopImmediatePropagation() } } async fieldTargetConnected(field) { this.debouncedToggle() } submitToGenericCardsView() { this.formTarget.action = this.cardsUrlValue this.formTarget.dataset.turboFrame = "top" this.formTarget.requestSubmit() } #toggle() { this.element.classList.toggle(this.filtersSetClass, this.#hasFiltersSet) } get #hasFiltersSet() { return this.fieldTargets.some(field => this.#isFieldSet(field)) } #isFieldSet(field) { const value = field.value?.trim() if (!value) return false const defaultValue = this.#defaultValueForField(field) return defaultValue ? value !== defaultValue : true } #defaultValueForField(field) { const comboboxContainer = field.closest("[data-combobox-default-value-value]") return comboboxContainer?.dataset?.comboboxDefaultValueValue } #refreshSaveToggleButton() { post(this.refreshUrlValue, { body: this.#collectFilterFormData(), responseKind: "turbo-stream" }) } #collectFilterFormData() { const formData = new FormData() this.formTargets.forEach(form => { const hiddenFields = form.querySelectorAll('input[type="hidden"]:not([disabled])[name]') hiddenFields.forEach(field => { formData.append(field.name, field.value) }) }) return formData } #showNoFilteringUrl() { Turbo.visit(this.noFilteringUrlValue, { frame: "cards_container", action: "advance" }) } } ================================================ FILE: app/javascript/controllers/form_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { debounce, nextFrame } from "helpers/timing_helpers"; export default class extends Controller { static targets = [ "cancel", "submit", "input" ] static values = { debounceTimeout: { type: Number, default: 300 } } #isComposing = false initialize() { this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue) } // IME Composition tracking compositionStart() { this.#isComposing = true } compositionEnd() { this.#isComposing = false } submit() { this.element.requestSubmit() } preventEmptySubmit(event) { const input = this.hasInputTarget ? this.inputTarget : null if (input) { const value = (input.value || "").trim() const isEmpty = value.length === 0 if (isEmpty) { event.preventDefault() input.setCustomValidity(input.dataset.validationMessage || "Please fill out this field") input.reportValidity() input.addEventListener("input", () => input.setCustomValidity(""), { once: true }) } } } preventComposingSubmit(event) { if (this.#isComposing) { event.preventDefault() } } debouncedSubmit(event) { this.submit(event) } submitToTopTarget(event) { const value = event.target.value?.trim() if (!value) return false this.element.setAttribute("data-turbo-frame", "_top") this.submit() } reset() { this.element.reset() } cancel() { this.cancelTarget?.click() } preventAttachment(event) { event.preventDefault() } async disableSubmitWhenInvalid(event) { await nextFrame() if (this.element.checkValidity()) { this.submitTarget.removeAttribute("disabled") } else { this.submitTarget.toggleAttribute("disabled", true) } } select(event) { event.target.select() } blurActiveInput() { document.activeElement?.blur() } } ================================================ FILE: app/javascript/controllers/frame_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { Turbo } from "@hotwired/turbo-rails" export default class extends Controller { morphRender({ detail }) { detail.render = function (currentElement, newElement) { Turbo.morphChildren(currentElement, newElement) } } morphReload(event) { const newElement = event.detail.newElement if (newElement && newElement.tagName === "TURBO-FRAME" && newElement.matches('[data-controller~="frame"]')) { event.preventDefault() this.element.reload() } } reload() { this.element.reload() } } ================================================ FILE: app/javascript/controllers/frame_reloader_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { reloadInterval: { type: Number, default: 10 * 60 } // 10 minutes } connect() { this.freshSince = Date.now() } reload() { const now = Date.now() const reloadIntervalMs = this.reloadIntervalValue * 1000 if ((now - this.freshSince) >= reloadIntervalMs) { this.freshSince = now this.element.reload() } } } ================================================ FILE: app/javascript/controllers/hotkey_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { click(event) { if (this.#isClickable && !this.#shouldIgnore(event)) { event.preventDefault() this.element.click() } } focus(event) { if (this.#isClickable && !this.#shouldIgnore(event)) { event.preventDefault() this.element.focus() } } #shouldIgnore(event) { return event.defaultPrevented || event.target.closest("input, textarea, lexxy-editor") } get #isClickable() { return getComputedStyle(this.element).pointerEvents !== "none" } } ================================================ FILE: app/javascript/controllers/index.js ================================================ // Import and register all your controllers from the importmap under controllers/* import { application } from "controllers/application" // Eager load all controllers defined in the import map under controllers/**/*_controller import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" eagerLoadControllersFrom("controllers", application) // Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) // import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" // lazyLoadControllersFrom("controllers", application) ================================================ FILE: app/javascript/controllers/knob_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "field", "option", "slider" ] connect() { this.#index = this.#selectedOption.dataset.index } optionChanged({ target }) { this.#index = target.dataset.index } sliderChanged({ target }) { this.#index = target.value } set #index(index) { this.fieldTarget.style.setProperty("--knob-index", `${index}`); this.sliderTarget.value = index this.#optionForIndex(index).checked = true } get #selectedOption() { return this.optionTargets.find(option => { return option.checked }) } #optionForIndex(index) { return this.optionTargets.find(option => { return option.dataset.index === index; }) } } ================================================ FILE: app/javascript/controllers/lightbox_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] imageTargetConnected(element) { element.addEventListener("click", this.#handleImageClick) } imageTargetDisconnected(element) { element.removeEventListener("click", this.#handleImageClick) } #handleImageClick = (event) => { event.preventDefault() this.#open(event.currentTarget) } #open(link) { this.dialogTarget.showModal() this.#set(link) } // Wait for the transition to finish before resetting the image handleTransitionEnd(event) { if (event.target === this.dialogTarget && !this.dialogTarget.open) { this.reset() } } reset() { this.zoomedImageTarget.src = "" this.captionTarget.innerHTML = " " this.dispatch('closed') } #set(target) { const imageSrc = target.href const caption = target.dataset.lightboxCaptionValue this.zoomedImageTarget.src = imageSrc if (caption) { this.captionTarget.innerText = caption } } } ================================================ FILE: app/javascript/controllers/local_save_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { debounce, nextFrame } from "helpers/timing_helpers" export default class extends Controller { static targets = ["input"] static values = { key: String } initialize() { this.save = debounce(this.save.bind(this), 300) } connect() { this.restoreContent() } submit({ detail: { success } }) { if (success) { this.#clear() } } save() { const content = this.inputTarget.value if (content) { localStorage.setItem(this.keyValue, content) } else { this.#clear() } } async restoreContent() { await nextFrame() let savedContent = localStorage.getItem(this.keyValue) if (savedContent) { savedContent = `
${savedContent}
` // temporary for old markdown saves this.inputTarget.value = savedContent this.#triggerChangeEvent(savedContent) } } // Private #clear() { localStorage.removeItem(this.keyValue) } #triggerChangeEvent(newValue) { if (this.inputTarget.tagName === "LEXXY-EDITOR") { this.inputTarget.dispatchEvent(new CustomEvent('lexxy:change', { bubbles: true, detail: { previousContent: '', newContent: newValue } })) } } } ================================================ FILE: app/javascript/controllers/local_time_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { differenceInDays, secondsToDate } from "helpers/date_helpers" const DEFAULT_LOCALE = "en-US" export default class extends Controller { static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago", "agoorweekday", "timeordate" ] static values = { refreshInterval: Number } static classes = [ "local-time-value"] #timer initialize() { this.timeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short" }) this.dateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { dateStyle: "long" }) this.shortdateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: "short", day: "numeric" }) this.datetimeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short", dateStyle: "short" }) this.agoFormatter = new AgoFormatter() this.daysagoFormatter = new DaysAgoFormatter() this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" }) this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" }) this.indaysFormatter = new InDaysFormatter() this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter() this.timeordateFormatter = new TimeOrDateFormatter() } connect() { this.#timer = setInterval(() => this.#refreshRelativeTimes(), 30_000) } disconnect() { clearInterval(this.#timer) } refreshAll() { this.constructor.targets.forEach(targetName => { this.targets.findAll(targetName).forEach(target => { this.#formatTime(this[`${targetName}Formatter`], target) }) }) } refreshTarget(event) { const target = event.target; const targetName = target.dataset.localTimeTarget this.#formatTime(this[`${targetName}Formatter`], target) } timeTargetConnected(target) { this.#formatTime(this.timeFormatter, target) } dateTargetConnected(target) { this.#formatTime(this.dateFormatter, target) } datetimeTargetConnected(target) { this.#formatTime(this.datetimeFormatter, target) } shortdateTargetConnected(target) { this.#formatTime(this.shortdateFormatter, target) } agoTargetConnected(target) { this.#formatTime(this.agoFormatter, target) } indaysTargetConnected(target) { this.#formatTime(this.indaysFormatter, target) } daysagoTargetConnected(target) { this.#formatTime(this.daysagoFormatter, target) } agoorweekdayTargetConnected(target) { this.#formatTime(this.agoorweekdayFormatter, target) } timeordateTargetConnected(target) { this.#formatTime(this.timeordateFormatter, target) } #refreshRelativeTimes() { this.agoTargets.forEach(target => { this.#formatTime(this.agoFormatter, target) }) } #formatTime(formatter, target) { const dt = secondsToDate(parseInt(target.getAttribute("datetime"))) target.innerHTML = formatter.format(dt) target.title = this.datetimeFormatter.format(dt) } } class AgoFormatter { format(dt) { const now = new Date() const seconds = (now - dt) / 1000 const minutes = seconds / 60 const hours = minutes / 60 const days = hours / 24 const weeks = days / 7 const months = days / (365 / 12) const years = days / 365 if (years >= 1) return this.#pluralize("year", years) if (months >= 1) return this.#pluralize("month", months) if (weeks >= 1) return this.#pluralize("week", weeks) if (days >= 1) return this.#pluralize("day", days) if (hours >= 1) return this.#pluralize("hour", hours) if (minutes >= 1) return this.#pluralize("minute", minutes) return "Less than a minute ago" } #pluralize(word, quantity) { quantity = Math.floor(quantity) const suffix = (quantity === 1) ? "" : "s" return `${quantity} ${word}${suffix} ago` } } class DaysAgoFormatter { format(date) { const days = differenceInDays(date, new Date()) if (days <= 0) return styleableValue("today") if (days === 1) return styleableValue("yesterday") return `${styleableValue(days)} days ago` } } class DaysAgoOrWeekdayFormatter { format(date) { const days = differenceInDays(date, new Date()) if (days <= 1) { return new DaysAgoFormatter().format(date) } else { return new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" }).format(date) } } } class InDaysFormatter { format(date) { const days = differenceInDays(new Date(), date) if (days <= 0) return styleableValue("today") if (days === 1) return styleableValue("tomorrow") return `in ${styleableValue(days)} days` } } class TimeOrDateFormatter { format(date) { const days = differenceInDays(date, new Date()) if (days >= 1) { return new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: "short", day: "numeric" }).format(date) } else { return new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short" }).format(date) } } } function styleableValue(value) { return `${value}` } ================================================ FILE: app/javascript/controllers/magic_link_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "input" ] submitOnEnter(event) { event.preventDefault() this.submit() } submitOnPaste() { onNextEventLoopTick(() => this.submit()) } submit() { if (this.inputTarget.disabled) return this.element.requestSubmit() this.inputTarget.disabled = true } } ================================================ FILE: app/javascript/controllers/multi_selection_combobox_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { toSentence } from "helpers/text_helpers" export default class extends Controller { #hiddenField static targets = [ "label", "item", "hiddenFieldTemplate" ] static values = { selectPropertyName: { type: String, default: "aria-checked" }, defaultValue: String, noSelectionLabel: { type: String, default: "No selection" }, labelPrefix: String } connect() { this.refresh() } change(event) { const item = event.target.closest("[role='checkbox']") if (item) { this.#toggleSelection(item) } } refresh() { this.labelTarget.textContent = this.#selectedLabel this.#updateHiddenFields() this.#updateFilterShow() } clear(event) { this.#deselectAll() this.#updateHiddenFields() this.labelTarget.textContent = this.#selectedLabel this.#updateFilterShow() } get #selectedLabel() { const selectedValues = this.#selectedValues() if (selectedValues.length === 0) { return this.noSelectionLabelValue } const labels = this.#selectedItems.map(item => item.dataset.multiSelectionComboboxLabel) const sentence = toSentence(labels, { two_words_connector: " or ", last_word_connector: ", or " }) return this.hasLabelPrefixValue ? `${this.labelPrefixValue} ${sentence}` : sentence } #toggleSelection(item) { const isSelected = item.getAttribute(this.selectPropertyNameValue) === "true" if (isSelected) { item.setAttribute(this.selectPropertyNameValue, "false") } else { if (this.isAnExclusiveSelectionItemInvolved(item)) { this.#deselectAll() } item.setAttribute(this.selectPropertyNameValue, "true") } this.#updateHiddenFields() if (item.dataset.multiSelectionFieldName) { this.#renameHiddenFields(item.dataset.multiSelectionFieldName) } this.labelTarget.textContent = this.#selectedLabel } isAnExclusiveSelectionItemInvolved(item) { return this.#isExclusiveSelection(item) || Array.from(this.#selectedItems).some((item) => this.#isExclusiveSelection(item)) } #isExclusiveSelection(item) { return item.dataset.multiSelectionExclusive === "true" } #updateHiddenFields() { this.#clearHiddenFields() this.#addHiddenFields() this.#updateFilterShow() } #deselectAll() { this.itemTargets.forEach(item => { item.setAttribute(this.selectPropertyNameValue, "false") }) } get #selectedItems() { return this.itemTargets.filter(item => item.getAttribute(this.selectPropertyNameValue) === "true" ) } #selectedValues() { return this.#selectedItems.map(item => item.dataset.multiSelectionComboboxValue) } #clearHiddenFields() { this.#hiddenFields.forEach(field => { field.remove() }) } #renameHiddenFields(fieldName) { this.#hiddenFields.forEach(field => { field.setAttribute("name", fieldName) }) } get #hiddenFields() { return this.element.querySelectorAll("input[type='hidden']") } #addHiddenFields() { this.#selectedValues().forEach(value => { const [ field ] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children field.removeAttribute("id") field.value = value this.element.appendChild(field) }) } #updateFilterShow() { const hasSelection = this.#selectedValues().length > 0 this.element.setAttribute("data-filter-show", hasSelection) } } ================================================ FILE: app/javascript/controllers/nav_section_expander_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { key: String } static targets = [ "input", "section" ] sectionTargetConnected() { this.#restoreToggles() } toggle(event) { const section = event.target if (section.hasAttribute("data-is-filtering")) return const key = this.#localStorageKeyFor(section) if (section.open) { localStorage.removeItem(key) } else { localStorage.setItem(key, true) } } showWhileFiltering() { if (this.inputTarget.value) { this.#expandAll(); } else { this.#restoreToggles() } } #expandAll() { this.sectionTargets.forEach(section => { section.setAttribute("data-is-filtering", true) section.open = true }) } #restoreToggles() { this.sectionTargets.forEach(section => { const key = this.#localStorageKeyFor(section) section.open = !localStorage.getItem(key) section.removeAttribute("data-is-filtering") }) } #localStorageKeyFor(section) { return section.getAttribute("data-nav-section-expander-key-value") } } ================================================ FILE: app/javascript/controllers/navigable_list_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { nextFrame } from "helpers/timing_helpers" import { isMobile } from "helpers/platform_helpers" export default class extends Controller { static targets = [ "item", "input" ] static values = { reverseOrder: { type: Boolean, default: false }, selectionAttribute: { type: String, default: "aria-selected" }, focusOnSelection: { type: Boolean, default: true }, actionableItems: { type: Boolean, default: false }, reverseNavigation: { type: Boolean, default: false }, supportsHorizontalNavigation: { type: Boolean, default: true }, supportsVerticalNavigation: { type: Boolean, default: true }, hasNestedNavigation: { type: Boolean, default: false }, preventHandledKeys: { type: Boolean, default: false }, autoSelect: { type: Boolean, default: true }, autoScroll: { type: Boolean, default: true }, onlyActOnFocusedItems: { type: Boolean, default: false } } // Don't load for mobile devices static get shouldLoad() { return !isMobile() } connect() { if (this.autoSelectValue) { this.reset() } else { this.#activateManualSelection() } } // Actions reset(event) { if (this.reverseOrderValue) { this.selectLast() } else { this.selectFirst() } } navigate(event) { this.#keyHandlers[event.key]?.call(this, event) this.#relayNavigationToParentNavigableList(event) } select({ target }) { this.selectItem(target, true) } hoverSelect({ currentTarget }) { this.selectItem(currentTarget) } selectCurrentOrReset(event) { if (this.currentItem) { this.#setCurrentFrom(this.currentItem) } else { this.reset() } } selectFirst() { this.#setCurrentFrom(this.#visibleItems[0]) } selectLast() { this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1]) } deselectWhenClickingOutside(event) { if (this.element.contains(event.target)) { return } this.#clearSelection() } // Public async selectItem(item, skipFocus = false) { await this.#selectCurrentElementInParent() this.#clearSelection() item.setAttribute(this.selectionAttributeValue, "true") this.currentItem = item this.#refreshActiveDescendant() await nextFrame() if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) } if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() } if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) } } isSelected(item) { return item === this.currentItem } // Private async #setCurrentFrom(element) { const selectedItem = this.#visibleItems.find(item => item.contains(element)) if (selectedItem) { await this.selectItem(selectedItem) } } get #parentNavigableListController() { const parentNavigableList = this.element.parentElement?.closest("[data-controller~='navigable-list']") if (parentNavigableList) { return this.application.getControllerForElementAndIdentifier(parentNavigableList, "navigable-list") } return null } async #selectCurrentElementInParent() { const parentController = this.#parentNavigableListController if (parentController) { const parentItem = this.element.closest("[data-navigable-list-target~='item']") const isAlreadySelected = parentController.isSelected(parentItem) if (!isAlreadySelected) { await parentController.selectItem(parentItem, true) } } } #clearSelection() { for (const item of this.itemTargets) { item.removeAttribute(this.selectionAttributeValue) } } #refreshActiveDescendant() { const id = this.currentItem?.getAttribute("id") if (this.hasInputTarget && id) { this.inputTarget.setAttribute("aria-activedescendant", id) } } #activateNestedNavigableList() { const nestedController = this.#nestedNavigableListController() if (nestedController) { nestedController.selectCurrentOrReset() return true } return false } #nestedNavigableListController() { const nestedElement = this.currentItem?.querySelector('[data-controller~="navigable-list"]') if (nestedElement) { return this.application.getControllerForElementAndIdentifier(nestedElement, "navigable-list") } return null } #activateManualSelection() { const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue)) if (preselectedItem) { this.#setCurrentFrom(preselectedItem) } } // Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers. #relayNavigationToParentNavigableList(event) { const parentController = this.#parentNavigableListController if (parentController) { parentController.element.focus({ preventScroll: !parentController.autoScrollValue }) parentController.navigate(event) } } #selectPrevious() { const index = this.#visibleItems.indexOf(this.currentItem) if (index > 0) { this.#setCurrentFrom(this.#visibleItems[index - 1]) } } #selectNext() { const index = this.#visibleItems.indexOf(this.currentItem) if (index >= 0 && index < this.#visibleItems.length - 1) { this.#setCurrentFrom(this.#visibleItems[index + 1]) } } #handleArrowKey(event, fn) { if (event.shiftKey || event.metaKey || event.ctrlKey) { return } fn.call() if (this.preventHandledKeysValue) { event.preventDefault() } } #clickCurrentItem(event) { if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) { const clickableElement = this.currentItem.querySelector("a,button") || this.currentItem clickableElement.click() event.preventDefault() } } get #isFocusContainedOnNavigableItem() { return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement)) } #toggleCurrentItem(event) { if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) { const toggleable = this.currentItem.querySelector("input[type=checkbox]") const isDisabled = toggleable.hasAttribute("disabled") if (toggleable) { if (!isDisabled) { toggleable.checked = !toggleable.checked toggleable.dispatchEvent(new Event('change', { bubbles: true })) } event.preventDefault() } } } get #visibleItems() { return this.itemTargets.filter(item => { return item.checkVisibility() && !item.hidden }) } // Public accessors for card_hotkeys_controller outlet get visibleItems() { return this.#visibleItems } clearSelection() { this.#clearSelection() this.currentItem = null } get hasFocus() { return this.element.contains(document.activeElement) } #keyHandlers = { ArrowDown(event) { if (this.supportsVerticalNavigationValue) { const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this) this.#handleArrowKey(event, selectMethod) } }, ArrowUp(event) { if (this.supportsVerticalNavigationValue) { const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this) this.#handleArrowKey(event, selectMethod) } }, ArrowRight(event) { if (this.supportsHorizontalNavigationValue) { this.#handleArrowKey(event, this.#selectNext.bind(this)) } }, ArrowLeft(event) { if (this.supportsHorizontalNavigationValue) { this.#handleArrowKey(event, this.#selectPrevious.bind(this)) } }, Enter(event) { // Skip handling during IME composition (e.g., Japanese input) if (event.isComposing) { return } if (event.shiftKey) { this.#toggleCurrentItem(event) } else { this.#clickCurrentItem(event) } } } } ================================================ FILE: app/javascript/controllers/notifications_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { post } from "@rails/request.js" export default class extends Controller { static classes = [ "enabled" ] static targets = [ "subscribeButton", "explainer" ] static values = { subscriptionsUrl: String } async connect() { if (!this.#allowed) return switch(Notification.permission) { case "default": this.#showButtonToSubscribe() break case "granted": const registration = await this.#getServiceWorkerRegistration() const subscription = await registration?.pushManager?.getSubscription() if (registration && subscription) { this.element.classList.add(this.enabledClass) } else { this.#showButtonToSubscribe() } break } } async attemptToSubscribe() { if (this.#allowed) { const registration = await this.#getServiceWorkerRegistration() || await this.#registerServiceWorker() switch(Notification.permission) { case "denied": { break } case "granted": { this.#subscribe(registration); break } case "default": { this.#requestPermissionAndSubscribe(registration) } } } } async isEnabled() { if (this.#allowed) { const registration = await this.#getServiceWorkerRegistration() const existingSubscription = await registration?.pushManager?.getSubscription() return Notification.permission == "granted" && registration && existingSubscription } } get #allowed() { return navigator.serviceWorker && window.Notification } async #getServiceWorkerRegistration() { return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) } async #registerServiceWorker() { await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) return navigator.serviceWorker.ready } async #subscribe(registration) { registration.pushManager .subscribe({ userVisibleOnly: true, applicationServerKey: this.#vapidPublicKey }) .then(subscription => { this.#syncPushSubscription(subscription) }) } async #syncPushSubscription(subscription) { const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" }) if (response.ok) { this.element.classList.add(this.enabledClass) this.subscribeButtonTarget.hidden = true } else { subscription.unsubscribe() } } #showButtonToSubscribe() { this.subscribeButtonTarget.hidden = false this.explainerTarget.hidden = true } async #requestPermissionAndSubscribe(registration) { const permission = await Notification.requestPermission() if (permission === "granted") this.#subscribe(registration) } get #vapidPublicKey() { const encodedVapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content return this.#urlBase64ToUint8Array(encodedVapidPublicKey) } #extractJsonPayloadAsString(subscription) { const { endpoint, keys: { p256dh, auth } } = subscription.toJSON() return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } }) } // VAPID public key comes encoded as base64 but service worker registration needs it as a Uint8Array #urlBase64ToUint8Array(base64String) { const padding = "=".repeat((4 - base64String.length % 4) % 4) const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") const rawData = window.atob(base64) const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i) } return outputArray } } ================================================ FILE: app/javascript/controllers/outlet_auto_save_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static outlets = [ "auto-save" ] change(event) { this.autoSaveOutlet.change(event) } submit() { this.autoSaveOutlet.submit() } } ================================================ FILE: app/javascript/controllers/pagination_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { createElement } from "helpers/html_helpers" import { delay, nextEvent } from "helpers/timing_helpers" import { keepingScrollPosition } from "helpers/scroll_helpers" import { get } from "@rails/request.js" const DELAY_BEFORE_OBSERVING = 400 export default class extends Controller { static targets = [ "paginationLink" ] static values = { paginateOnIntersection: { type: Boolean, default: false }, discardFrame: Boolean, manualActivation: Boolean } initialize() { if (!this.manualActivation) { this.activate() } } disconnect() { this.observer?.disconnect() } async activate() { await delay(DELAY_BEFORE_OBSERVING) if (this.paginateOnIntersectionValue) { this.observer = new IntersectionObserver(this.#intersect, { rootMargin: "300px", threshold: 1 }) } } async paginationLinkTargetConnected(linkElement) { if (this.paginateOnIntersectionValue) { await delay(DELAY_BEFORE_OBSERVING) this.observer?.observe(linkElement) } } // Actions loadPage({ target }) { this.#loadPaginationLink(target) } // Private #intersect = ([ entry ]) => { if (entry?.isIntersecting && entry.intersectionRatio === 1) { this.#loadPaginationLink(entry.target) } } #loadPaginationLink(linkElement) { this.observer?.unobserve(linkElement) keepingScrollPosition(this.#closestSiblingTo(linkElement) || linkElement.parentNode, this.#expandPaginationLink(linkElement)) } #closestSiblingTo(element) { return element.nextElementSibling || element.previousElementSibling } async #expandPaginationLink(linkElement) { linkElement.setAttribute("aria-busy", "true") if (this.discardFrameValue) { await this.#replacePaginationLinkWithFrameContents(linkElement) } else { await this.#replacePaginationLinkWithFrame(linkElement) } linkElement.removeAttribute("aria-busy") } async #replacePaginationLinkWithFrameContents(linkElement) { linkElement.outerHTML = await this.#loadHtmlFrom(linkElement) } async #loadHtmlFrom(linkElement) { const response = await get(linkElement.href, { responseKind: "html" }) const html = await response.text const doc = new DOMParser().parseFromString(html, "text/html") const element = doc.querySelector(`turbo-frame#${linkElement.dataset.frame}`) return element ? element.innerHTML.trim() : "" } #replacePaginationLinkWithFrame(linkElement) { const turboFrame = this.#buildTurboFrameFor(linkElement) this.#insertTurboFrameAtPosition(linkElement, turboFrame) } #buildTurboFrameFor(linkElement) { const turboFrame = createElement("turbo-frame", { id: linkElement.dataset.frame, src: linkElement.href, refresh: "morph", target: "_top" }) this.#keepScrollPositionOnFrameRender(turboFrame, linkElement) return turboFrame } async #keepScrollPositionOnFrameRender(turboFrame, linkElement) { await nextEvent(turboFrame, "turbo:before-frame-render") keepingScrollPosition(linkElement, nextEvent(turboFrame, "turbo:frame-render")) } #insertTurboFrameAtPosition(linkElement, turboFrame) { const container = linkElement.parentNode.parentNode if (linkElement.parentNode.firstElementChild === linkElement) { container.prepend(turboFrame) } else { container.append(turboFrame) } } } ================================================ FILE: app/javascript/controllers/reaction_delete_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = [ "deleteable", "reveal", "perform" ] static targets = [ "button", "content" ] static values = { reacterId: String } connect() { if (this.#currentUserIsReacter) { this.#setAccessibleAttributes() } } reveal() { if (this.#currentUserIsReacter) { this.element.classList.toggle(this.revealClass) this.contentTarget.ariaExpanded = this.element.classList.contains(this.revealClass) this.buttonTarget.focus() } } perform() { this.element.classList.add(this.performClass) } #setAccessibleAttributes() { this.contentTarget.role = "button" this.contentTarget.tabIndex = 0 this.contentTarget.ariaExpanded = false this.element.classList.add(this.deleteableClass) } get #currentUserIsReacter() { return Current.user.id === this.reacterIdValue } } ================================================ FILE: app/javascript/controllers/reaction_emoji_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "input" ] insertEmoji(event) { const emojiChar = event.target.getAttribute("data-emoji") const value = this.inputTarget.value const newValue = `${value}${emojiChar}` if (this.inputTarget.maxLength > 0 && newValue.length <= this.inputTarget.maxLength) { this.inputTarget.value = newValue } this.inputTarget.focus() } } ================================================ FILE: app/javascript/controllers/related_element_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "related" ] static classes = [ "highlight" ] connect() { this.#highlight(null) } highlight(event) { this.#highlight(event.currentTarget.dataset.relatedElementGroupValue) } unhighlight() { this.#highlight(null) } #highlight(groupValue) { this.relatedTargets.forEach(element => element.classList.toggle(this.highlightClass, element.dataset.relatedElementGroupValue === groupValue) ) } } ================================================ FILE: app/javascript/controllers/retarget_links_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.element.querySelectorAll("a").forEach(this.#retargetLink.bind(this)) } #retargetLink(link) { link.target = this.#targetsSameDomain(link) ? "_top" : "_blank" } #targetsSameDomain(link) { return link.href.startsWith(window.location.origin) } } ================================================ FILE: app/javascript/controllers/scroll_to_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "target" ] connect() { this.#scrollTargetIntoView() } #scrollTargetIntoView() { if(this.hasTargetTarget) { this.element.scrollTo({ top: this.targetTarget.offsetTop - this.element.offsetHeight / 2 + this.targetTarget.offsetHeight / 2, left: this.targetTarget.offsetLeft - this.element.offsetWidth / 2 + this.targetTarget.offsetWidth / 2, behavior: "instant" }) } } } ================================================ FILE: app/javascript/controllers/search_form_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["searchInput"] clearInput() { if (this.searchInputTarget.value) { this.searchInputTarget.value = "" this.searchInputTarget.focus() } else { this.dispatch("reset") } } } ================================================ FILE: app/javascript/controllers/soft_keyboard_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { nextEventNamed } from "helpers/timing_helpers" import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { // Only load for touch devices static get shouldLoad() { return isTouchDevice() } // Use a fake input to trigger the soft keyboard on actions that load async content // See https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa async open(event) { const fakeInput = this.#focusOnFakeInput() this.#removeOnFocusOut(fakeInput) } #focusOnFakeInput() { const fakeInput = document.createElement("input") fakeInput.setAttribute("type", "text") fakeInput.setAttribute("class", "input--invisible") this.element.appendChild(fakeInput) fakeInput.focus() return fakeInput } async #removeOnFocusOut(element) { await nextEventNamed("focusout", element) element.remove() } } ================================================ FILE: app/javascript/controllers/syntax_highlight_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { highlightAll } from "lexxy" export default class extends Controller { connect() { highlightAll() } } ================================================ FILE: app/javascript/controllers/theme_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["lightButton", "darkButton", "autoButton"] connect() { this.#applyStoredTheme() } setLight() { this.#theme = "light" } setDark() { this.#theme = "dark" } setAuto() { this.#theme = "auto" } get #storedTheme() { return localStorage.getItem("theme") || "auto" } set #theme(theme) { localStorage.setItem("theme", theme) const currentTheme = document.documentElement.getAttribute("data-theme") || "auto" const hasChanged = currentTheme !== theme const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches const animate = hasChanged && !prefersReducedMotion const applyTheme = () => { if (theme === "auto") { document.documentElement.removeAttribute("data-theme") } else { document.documentElement.setAttribute("data-theme", theme) } this.#updateButtons() } if (animate && document.startViewTransition) { document.startViewTransition(applyTheme) } else { applyTheme() } } #applyStoredTheme() { this.#theme = this.#storedTheme } #updateButtons() { const storedTheme = this.#storedTheme if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") } if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") } if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") } } } ================================================ FILE: app/javascript/controllers/timezone_cookie_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { this.#setTimezoneCookie() } #setTimezoneCookie() { const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone document.cookie = `timezone=${encodeURIComponent(timezone)}; path=/` } } ================================================ FILE: app/javascript/controllers/toggle_class_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static classes = [ "toggle" ] static targets = [ "checkbox" ] toggle() { this.element.classList.toggle(this.toggleClass) } add() { this.element.classList.add(this.toggleClass) } remove() { this.element.classList.remove(this.toggleClass) } checkAll() { this.checkboxTargets.forEach(checkbox => { checkbox.checked = true }) } checkNone() { this.checkboxTargets.forEach(checkbox => { if (checkbox.dataset.boardsFormTarget === "meCheckbox") return checkbox.checked = false }) } } ================================================ FILE: app/javascript/controllers/toggle_enable_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "element" ] toggle() { this.elementTargets.forEach((element) => { element.toggleAttribute("disabled") }) } } ================================================ FILE: app/javascript/controllers/tooltip_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" export default class extends Controller { static targets = [ "tooltip" ] connect() { this.element.addEventListener("mouseenter", this.mouseEnter.bind(this)) this.element.addEventListener("mouseout", this.mouseOut.bind(this)) } disconnect() { this.element.removeEventListener("mouseenter", this.mouseEnter.bind(this)) this.element.removeEventListener("mouseout", this.mouseOut.bind(this)) } mouseEnter(event) { orient({ target: this.#tooltipElement, anchor: this.element }) } mouseOut(event) { orient({ target: this.#tooltipElement, reset: true }) } get #tooltipElement() { return this.element.querySelector(".for-screen-reader") } get #tooltipText() { return this.#tooltipElement.innerText } } ================================================ FILE: app/javascript/controllers/touch_placeholder_controller.js ================================================ import { Controller } from "@hotwired/stimulus" import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { static get shouldLoad() { return isTouchDevice() } static values = { placeholder: String } connect() { if (this.hasPlaceholderValue) { this.element.placeholder = this.placeholderValue } } } ================================================ FILE: app/javascript/controllers/turbo_navigation_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { label: String } static targets = [ "referrerBackLink" ] rememberLocation() { sessionStorage.setItem("referrerUrl", window.location.href) sessionStorage.setItem("referrerLabel", this.labelValue) } backIfSamePath(event) { if (event.ctrlKey || event.metaKey || event.shiftKey) { return } const link = event.target.closest("a") const targetUrl = new URL(link.href) if (this.#referrerPath && targetUrl.pathname === this.#referrerPath) { event.preventDefault() Turbo.visit(this.#referrerUrl) } } referrerBackLinkTargetConnected(link) { if (!this.#referrerUrl || !this.#referrerLabel) { return } const stripTrailingSlash = path => path.replace(/\/$/, "") const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || "").split(",").map(stripTrailingSlash) const referrerPath = stripTrailingSlash(new URL(this.#referrerUrl).pathname) if (!allowedPaths.includes(referrerPath)) { return } link.href = this.#referrerUrl const strong = link.querySelector("strong") if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` } } get #referrerPath() { if (!this.#referrerUrl) return null return new URL(this.#referrerUrl).pathname } get #referrerUrl() { return sessionStorage.getItem("referrerUrl") } get #referrerLabel() { return sessionStorage.getItem("referrerLabel") } } ================================================ FILE: app/javascript/controllers/upload_preview_controller.js ================================================ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "image", "input", "fileName", "placeholder" ] previewImage() { if (this.#file) { this.imageTarget.src = URL.createObjectURL(this.#file) this.imageTarget.onload = () => URL.revokeObjectURL(this.imageTarget.src) } } previewFileName() { this.#file ? this.#showFileName() : this.#showPlaceholder() } #showFileName() { this.fileNameTarget.innerText = this.#file.name this.fileNameTarget.removeAttribute("hidden") this.placeholderTarget.setAttribute("hidden", true) } #showPlaceholder() { this.placeholderTarget.removeAttribute("hidden") this.fileNameTarget.setAttribute("hidden", true) } get #file() { return this.inputTarget.files[0] } } ================================================ FILE: app/javascript/helpers/bridge/viewport_helpers.js ================================================ let top = 0 const viewportTarget = window.visualViewport || window export const viewport = { get top() { return top }, get height() { return viewportTarget.height || window.innerHeight } } function update() { requestAnimationFrame(() => { const styles = getComputedStyle(document.documentElement) const customInset = styles.getPropertyValue("--custom-safe-inset-top") const fallbackInset = styles.getPropertyValue("--safe-area-inset-top") const insetValue = (customInset || fallbackInset).trim() top = parseInt(insetValue || "0", 10) || 0 }) } viewportTarget.addEventListener("resize", update) update() ================================================ FILE: app/javascript/helpers/date_helpers.js ================================================ export function differenceInDays(fromDate, toDate) { return Math.round(Math.abs((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24))) } export function signedDifferenceInDays(fromDate, toDate) { return Math.round((beginningOfDay(toDate) - beginningOfDay(fromDate)) / (1000 * 60 * 60 * 24)) } export function beginningOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()) } export function secondsToDate(seconds) { return new Date(seconds * 1000) } ================================================ FILE: app/javascript/helpers/form_helpers.js ================================================ import { FetchRequest } from "@rails/request.js" export async function submitForm(form) { const request = new FetchRequest(form.method, form.action, { body: new FormData(form) }) return await request.perform() } ================================================ FILE: app/javascript/helpers/html_helpers.js ================================================ export function createElement(name, properties) { const element = document.createElement(name) for (var key in properties) { element.setAttribute(key, properties[key]) } return element } ================================================ FILE: app/javascript/helpers/orientation_helpers.js ================================================ const EDGE_THRESHOLD = 16 export function orient({ target, anchor = null, reset = false }) { target.classList.remove("orient-left", "orient-right") target.style.removeProperty("--orient-offset") if (reset) return const targetGeometry = geometry(target) const anchorGeometry = geometry(anchor) const shouldOrientLeft = targetGeometry.spaceOnRight < EDGE_THRESHOLD && targetGeometry.spaceOnRight < targetGeometry.spaceOnLeft const shouldOrientRight = targetGeometry.spaceOnLeft < EDGE_THRESHOLD && targetGeometry.spaceOnLeft < targetGeometry.spaceOnRight if (shouldOrientLeft) { orientLeft({ el: target, targetGeometry, anchorGeometry }) } else if (shouldOrientRight) { orientRight({ el: target, targetGeometry, anchorGeometry }) } } function orientLeft({ el, targetGeometry, anchorGeometry }) { const offset = Math.min(0, anchorGeometry.spaceOnLeft + anchorGeometry.width - targetGeometry.width) * -1 el.classList.add("orient-left") el.style.setProperty("--orient-offset", `${offset}px`) } function orientRight({ el, targetGeometry, anchorGeometry }) { const offset = Math.max(0, anchorGeometry.spaceOnLeft + targetGeometry.width - window.innerWidth) * -1 el.classList.add("orient-right") el.style.setProperty("--orient-offset", `${offset}px`) } function geometry(el) { const rect = el.getBoundingClientRect() return { spaceOnLeft: rect.left, spaceOnRight: window.innerWidth - rect.right, width: rect.width } } ================================================ FILE: app/javascript/helpers/platform_helpers.js ================================================ export function isTouchDevice() { return "ontouchstart" in window && navigator.maxTouchPoints > 0 } export function isIos() { return /iPhone|iPad/.test(navigator.userAgent) } export function isAndroid() { return /Android/.test(navigator.userAgent) } export function isMobile() { return isIos() || isAndroid() } export function isNative() { return /Hotwire Native/.test(navigator.userAgent) } ================================================ FILE: app/javascript/helpers/scroll_helpers.js ================================================ export async function keepingScrollPosition(element, promise) { const originalPosition = element.getBoundingClientRect() await promise const currentPosition = element.getBoundingClientRect() const yDiff = currentPosition.top - originalPosition.top const xDiff = currentPosition.left - originalPosition.left findNearestScrollableYAncestor(element).scrollTop += yDiff findNearestScrollableXAncestor(element).scrollLeft += xDiff } export function isFullyVisible(element, container = document.documentElement) { const elementRect = element.getBoundingClientRect() const containerRect = container.getBoundingClientRect() return elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom && elementRect.left >= containerRect.left && elementRect.right <= containerRect.right } export function isScrolledToBottom(element, threshold = 100) { return (element.scrollHeight - element.scrollTop - element.clientHeight) < threshold } export function scrollToBottom(element) { element.scrollTop = element.scrollHeight } export function scrollIntoView(element, options = { inline: "center", block: "center", behavior: "instant" }) { element.scrollIntoView(options) } // Private function findNearestScrollableYAncestor(refElement) { return findNearest(refElement, (element) => { const largerThanVisibleArea = element.scrollHeight > element.clientHeight const overflowY = getComputedStyle(element).overflowY const scrollableStyle = overflowY === "scroll" || overflowY === "auto" return largerThanVisibleArea && scrollableStyle }) } function findNearestScrollableXAncestor(refElement) { return findNearest(refElement, (element) => { const largerThanVisibleArea = element.scrollWidth > element.clientWidth const overflowX = getComputedStyle(element).overflowX const scrollableStyle = overflowX === "scroll" || overflowX === "auto" return largerThanVisibleArea && scrollableStyle }) } function findNearest(element, fn) { while (element) { if (fn(element)) { return element } else { element = element.parentElement } } return document.documentElement } ================================================ FILE: app/javascript/helpers/text_helpers.js ================================================ export function isMultiLineString(string) { return /\r|\n/.test(string) } export function normalizeFilteredText(string) { return string .toLowerCase() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Remove diacritics } export function filterMatches(text, potentialMatch) { return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch)) } export function toSentence(array, options = {}) { const defaultConnectors = { words_connector: ", ", two_words_connector: " and ", last_word_connector: ", and " } const connectors = { ...defaultConnectors, ...options } if (array.length === 0) { return "" } if (array.length === 1) { return array[0] } if (array.length === 2) { return array.join(connectors.two_words_connector) } return array.slice(0, -1).join(connectors.words_connector) + connectors.last_word_connector + array[array.length - 1] } ================================================ FILE: app/javascript/helpers/timing_helpers.js ================================================ export function throttle(fn, delay = 1000) { let timeoutId = null return (...args) => { if (!timeoutId) { fn(...args) timeoutId = setTimeout(() => timeoutId = null, delay) } } } export function debounce(fn, delay = 1000) { let timeoutId = null return (...args) => { clearTimeout(timeoutId) timeoutId = setTimeout(() => fn.apply(this, args), delay) } } export function nextEventLoopTick() { return delay(0) } export function onNextEventLoopTick(callback) { setTimeout(callback, 0) } export function nextEvent(element, eventName) { return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true })) } export function nextFrame() { return new Promise(requestAnimationFrame) } export function nextEventNamed(eventName, element = window) { return new Promise(resolve => element.addEventListener(eventName, resolve, { once: true })) } export function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } ================================================ FILE: app/javascript/initializers/bridge/bridge_element.js ================================================ import { BridgeElement } from "@hotwired/hotwire-native-bridge" BridgeElement.prototype.getButton = function() { return { title: this.title, icon: this.getIcon(), displayTitle: this.getDisplayTitle(), displayAsPrimaryAction: this.getDisplayAsPrimaryAction(), slot: this.getSlot() } } BridgeElement.prototype.getIcon = function() { const url = this.bridgeAttribute("icon-url") if (url) { return { url } } return null } BridgeElement.prototype.getDisplayTitle = function() { return !!this.bridgeAttribute("display-title") } BridgeElement.prototype.getDisplayAsPrimaryAction = function() { return !!this.bridgeAttribute("display-as-primary-action") } BridgeElement.prototype.getSlot = function () { return this.bridgeAttribute("slot") ?? "right" } ================================================ FILE: app/javascript/initializers/current.js ================================================ class Current { get user() { const currentUserId = this.#extractContentFromMetaTag("current-user-id") if (currentUserId) { return { id: currentUserId } } } #extractContentFromMetaTag(name) { return document.head.querySelector(`meta[name="${name}"]`)?.getAttribute("content") } } window.Current = new Current() ================================================ FILE: app/javascript/initializers/index.js ================================================ import "initializers/current" import "initializers/bridge/bridge_element" import "initializers/offline" import "initializers/lexxy_markdown_paste" ================================================ FILE: app/javascript/initializers/lexxy_markdown_paste.js ================================================ document.addEventListener("lexxy:insert-markdown", (event) => { event.detail.addBlockSpacing() }) ================================================ FILE: app/javascript/initializers/offline.js ================================================ import { Turbo } from "@hotwired/turbo-rails" if (Current.user) { Turbo.offline.start("/service-worker.js", { scope: "/", native: true, preload: /\/assets\// }) } ================================================ FILE: app/javascript/lib/action_pack/passkey.js ================================================ // JS companion for the ActionPack::Passkey Ruby helpers. // // Binds click handlers to passkey buttons and manages the WebAuthn ceremony // lifecycle (challenge refresh, credential creation/authentication, form submission). // // Expected data attributes: // [data-passkey="create"] — triggers the registration ceremony // [data-passkey="sign_in"] — triggers the authentication ceremony // [data-passkey-mediation="conditional"] — on a
, enables autofill-assisted sign in // [data-passkey-errors] — container whose data-passkey-error-state is set on failure // [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state // [data-passkey-field="..."] — hidden fields populated before form submission // // Custom events (all bubble): // passkey:start — ceremony begun // passkey:success — credential obtained, form about to submit // passkey:error — ceremony failed; detail: { error, cancelled } // // Meta tags (rendered by the Ruby form helpers): // — JSON WebAuthn creation options // — JSON WebAuthn request options // — endpoint to refresh the challenge nonce import { register, authenticate } from "lib/action_pack/webauthn" let listeners let currentDocument document.addEventListener("DOMContentLoaded", setup) document.addEventListener("turbo:load", setup) document.addEventListener("turbo:before-cache", teardown) // Set error state on the nearest [data-passkey-errors] container. // The app's CSS is responsible for showing/hiding children based on // the data-passkey-error-state attribute ("error" or "cancelled"). document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => { const container = target.closest("[data-passkey-errors]") if (container) { container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" } }) // Bind click handlers to passkey buttons and attempt conditional mediation. // Guards against duplicate setup. function setup() { if (currentDocument !== document.documentElement) { currentDocument = document.documentElement listeners?.abort() listeners = new AbortController() for (const button of document.querySelectorAll('[data-passkey="create"]')) { button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal }) } for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) { button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal }) } attemptConditionalMediation() } } // Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers. function teardown() { currentDocument = null listeners?.abort() for (const button of document.querySelectorAll('[data-passkey][disabled]')) { button.disabled = false } for (const container of document.querySelectorAll("[data-passkey-errors]")) { delete container.dataset.passkeyErrorState } } // Run the WebAuthn registration ceremony: refresh the challenge, prompt the // browser to create a credential, fill the form's hidden fields, and submit. async function createPasskey(button) { const form = button.closest("form") if (form) { button.disabled = true button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") const creationOptions = getCreationOptions() if (!creationOptions) throw new Error("Missing passkey creation options") await refreshChallenge(creationOptions) const passkey = await register(creationOptions) button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) fillCreateForm(form, passkey) form.submit() } catch (error) { button.disabled = false const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) } } } function passkeysAvailable() { return !!window.PublicKeyCredential } // Read WebAuthn creation options from the tag rendered by // +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing. function getCreationOptions() { return getOptions("passkey-creation-options") } // Parse and return the JSON content of a tag by name. function getOptions(name) { const meta = document.querySelector(`meta[name="${name}"]`) if (meta) { return JSON.parse(meta.content) } } // POST to the challenge endpoint to get a fresh nonce, preventing replay attacks // when the page has been open for a while before the user initiates the ceremony. async function refreshChallenge(options) { const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content if (!url) throw new Error("Missing passkey challenge URL") const token = document.querySelector('meta[name="csrf-token"]')?.content const response = await fetch(url, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": token, "Accept": "application/json" } }) if (!response.ok) throw new Error("Failed to refresh challenge") const { challenge } = await response.json() options.challenge = challenge } // Populate the registration form's hidden fields with the credential response. // Clones the transports template input for each reported transport. function fillCreateForm(form, passkey) { form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object const template = form.querySelector('[data-passkey-field="transports"]') for (const transport of passkey.transports) { const input = template.cloneNode() input.value = transport template.before(input) } template.remove() } // Run the WebAuthn authentication ceremony: refresh the challenge, prompt the // browser to sign with an existing credential, fill the form, and submit. async function signInWithPasskey(button) { const form = button.closest("form") if (form) { button.disabled = true button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) try { if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") const requestOptions = getRequestOptions() if (!requestOptions) throw new Error("Missing passkey request options") await refreshChallenge(requestOptions) const passkey = await authenticate(requestOptions) button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) fillSignInForm(form, passkey) form.submit() } catch (error) { button.disabled = false const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) } } } // Read WebAuthn request options from the tag rendered by // +passkey_request_options_meta_tag+. Returns undefined if the tag is missing. function getRequestOptions() { return getOptions("passkey-request-options") } // Populate the authentication form's hidden fields with the assertion response. function fillSignInForm(form, passkey) { form.querySelector('[data-passkey-field="id"]').value = passkey.id form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data form.querySelector('[data-passkey-field="signature"]').value = passkey.signature } // Start the conditional mediation (autofill) ceremony if the page opts in with // a form[data-passkey-mediation="conditional"] and the browser supports it. // Unlike the button-driven ceremonies, this runs automatically on page load. async function attemptConditionalMediation() { if (await conditionalMediationAvailable()) { const form = document.querySelector('form[data-passkey-mediation="conditional"]') form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) const requestOptions = getRequestOptions() try { await refreshChallenge(requestOptions) const passkey = await authenticate(requestOptions, { mediation: "conditional" }) form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) fillSignInForm(form, passkey) form.submit() } catch (error) { const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) } } } // Check all preconditions for conditional mediation: the page has opted in, // request options are present, the browser supports passkeys, and the browser // supports the conditional mediation UI (autofill). async function conditionalMediationAvailable() { return isConditionalMediationFormPresent() && getRequestOptions() && passkeysAvailable() && await window.PublicKeyCredential.isConditionalMediationAvailable?.() } function isConditionalMediationFormPresent() { return !!document.querySelector('form[data-passkey-mediation="conditional"]') } ================================================ FILE: app/javascript/lib/action_pack/webauthn.js ================================================ // Thin wrapper around the browser WebAuthn API (navigator.credentials). // // Handles the base64url ↔ ArrayBuffer conversions required by the spec so // callers can work with plain JSON objects from the server-rendered meta tags. // Call navigator.credentials.create() with the given creation options. // Returns { client_data_json, attestation_object, transports } with all // binary fields encoded as base64url strings ready for form submission. export async function register(options) { const publicKey = prepareCreationOptions(options) const credential = await navigator.credentials.create({ publicKey }) return { client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), attestation_object: bufferToBase64url(credential.response.attestationObject), transports: credential.response.getTransports?.() || [] } } // Call navigator.credentials.get() with the given request options. // Accepts an optional signal (AbortSignal) and mediation hint ("conditional" // for autofill UI). Returns { id, client_data_json, authenticator_data, signature } // with binary fields encoded as base64url strings. export async function authenticate(options, { signal, mediation } = {}) { const publicKey = prepareRequestOptions(options) const credential = await navigator.credentials.get({ publicKey, signal, mediation }) return { id: credential.id, client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), authenticator_data: bufferToBase64url(credential.response.authenticatorData), signature: bufferToBase64url(credential.response.signature) } } // Convert JSON creation options into the format expected by the browser: // decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers. function prepareCreationOptions(options) { return { ...options, challenge: base64urlToBuffer(options.challenge), user: { ...options.user, id: base64urlToBuffer(options.user.id) }, excludeCredentials: (options.excludeCredentials || []).map(cred => ({ ...cred, id: base64urlToBuffer(cred.id) })) } } // Convert JSON request options into the format expected by the browser: // decode base64url challenge and allowCredentials[].id into ArrayBuffers. // Strips allowCredentials entirely when empty so the browser prompts for // any available credential (required for conditional mediation). function prepareRequestOptions(options) { const prepared = { ...options, challenge: base64urlToBuffer(options.challenge) } if (options.allowCredentials?.length) { prepared.allowCredentials = options.allowCredentials.map(cred => ({ ...cred, id: base64urlToBuffer(cred.id) })) } else { delete prepared.allowCredentials } return prepared } function base64urlToBuffer(base64url) { const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") const padding = "=".repeat((4 - base64.length % 4) % 4) const binary = atob(base64 + padding) return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer } function bufferToBase64url(buffer) { const bytes = new Uint8Array(buffer) const binary = String.fromCharCode(...bytes) return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } ================================================ FILE: app/jobs/account/data_import_job.rb ================================================ class Account::DataImportJob < ApplicationJob include ActiveJob::Continuable queue_as :backend discard_on Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError def perform(import) step :check do |step| import.check \ start: step.cursor, callback: ->(record_set:, file:) { step.set!([ record_set.model.name, file ]) } end step :process do |step| import.process \ start: step.cursor, callback: ->(record_set:, files:) { step.set!([ record_set.model.name, files.last ]) } end end end ================================================ FILE: app/jobs/account/incinerate_due_job.rb ================================================ class Account::IncinerateDueJob < ApplicationJob include ActiveJob::Continuable queue_as :incineration def perform step :incineration do |step| Account.due_for_incineration.find_each do |account| account.incinerate step.checkpoint! end end end end ================================================ FILE: app/jobs/application_job.rb ================================================ class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked # Most jobs are safe to ignore if the underlying records are no longer available # discard_on ActiveJob::DeserializationError end ================================================ FILE: app/jobs/board/clean_inaccessible_data_job.rb ================================================ class Board::CleanInaccessibleDataJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(user, board) board.clean_inaccessible_data_for(user) end end ================================================ FILE: app/jobs/card/activity_spike/detection_job.rb ================================================ class Card::ActivitySpike::DetectionJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(card) card.detect_activity_spikes end end ================================================ FILE: app/jobs/card/clean_inaccessible_data_job.rb ================================================ class Card::CleanInaccessibleDataJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(card) card.clean_inaccessible_data end end ================================================ FILE: app/jobs/card/remove_inaccessible_notifications_job.rb ================================================ class Card::RemoveInaccessibleNotificationsJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(card) card.remove_inaccessible_notifications end end ================================================ FILE: app/jobs/concerns/smtp_delivery_error_handling.rb ================================================ module SmtpDeliveryErrorHandling extend ActiveSupport::Concern included do # Retry delivery to possibly-unavailable remote mailservers. retry_on Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError, wait: :polynomially_longer # Net::SMTPServerBusy is SMTP error code 4xx, a temporary error. # Common one we've seen is 452 4.3.1 Insufficient system storage. # Patiently retry. retry_on Net::SMTPServerBusy, wait: :polynomially_longer # SMTP error 50x. rescue_from Net::SMTPSyntaxError do |error| case error.message when /\A501 5\.1\.3/ # Ignore undeliverable email addresses. Sentry.capture_exception error, level: :info if Fizzy.saas? else raise end end # SMTP error 5xx except 50x and 53x. # * 550 5.1.1: Unknown users # * 552 5.6.0: Message/headers too large rescue_from Net::SMTPFatalError do |error| case error.message when /\A550 5\.1\.1/, /\A552 5\.6\.0/, /\A555 5\.5\.4/ Sentry.capture_exception error, level: :info if Fizzy.saas? else raise end end end end ================================================ FILE: app/jobs/data_export_job.rb ================================================ class DataExportJob < ApplicationJob queue_as :backend discard_on ActiveJob::DeserializationError def perform(export) export.build end end ================================================ FILE: app/jobs/delete_unused_tags_job.rb ================================================ class DeleteUnusedTagsJob < ApplicationJob def perform Tag.unused.find_each do |tag| tag.destroy! end end end ================================================ FILE: app/jobs/event/webhook_dispatch_job.rb ================================================ require "active_job/continuable" class Event::WebhookDispatchJob < ApplicationJob include ActiveJob::Continuable queue_as :webhooks discard_on ActiveJob::DeserializationError def perform(event) step :dispatch do |step| Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook| webhook.trigger(event) step.advance! from: webhook.id end end end end ================================================ FILE: app/jobs/mention/create_job.rb ================================================ class Mention::CreateJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(record, mentioner:) record.create_mentions(mentioner:) end end ================================================ FILE: app/jobs/notification/bundle/deliver_all_job.rb ================================================ class Notification::Bundle::DeliverAllJob < ApplicationJob queue_as :backend discard_on ActiveJob::DeserializationError def perform Notification::Bundle.deliver_all end end ================================================ FILE: app/jobs/notification/bundle/deliver_job.rb ================================================ class Notification::Bundle::DeliverJob < ApplicationJob include SmtpDeliveryErrorHandling queue_as :backend discard_on ActiveJob::DeserializationError def perform(bundle) bundle.deliver end end ================================================ FILE: app/jobs/notification/push_job.rb ================================================ class Notification::PushJob < ApplicationJob def perform(notification) notification.push end end ================================================ FILE: app/jobs/notify_recipients_job.rb ================================================ class NotifyRecipientsJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(notifiable) notifiable.notify_recipients end end ================================================ FILE: app/jobs/push_notification_job.rb ================================================ class PushNotificationJob < ApplicationJob discard_on ActiveJob::DeserializationError def perform(notification) notification.push end end ================================================ FILE: app/jobs/storage/materialize_job.rb ================================================ class Storage::MaterializeJob < ApplicationJob queue_as :backend limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError def perform(owner) owner.materialize_storage end end ================================================ FILE: app/jobs/storage/reconcile_job.rb ================================================ class Storage::ReconcileJob < ApplicationJob class ReconcileAborted < StandardError; end queue_as :backend limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError retry_on ReconcileAborted, wait: 1.minute, attempts: 3 def perform(owner) raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage end end ================================================ FILE: app/jobs/webhook/delivery_job.rb ================================================ class Webhook::DeliveryJob < ApplicationJob queue_as :webhooks discard_on ActiveJob::DeserializationError def perform(delivery) delivery.deliver end end ================================================ FILE: app/mailers/account_mailer.rb ================================================ class AccountMailer < ApplicationMailer def cancellation(cancellation) @account = cancellation.account @user = cancellation.initiated_by mail( to: @user.identity.email_address, subject: "Your Fizzy account was cancelled" ) end end ================================================ FILE: app/mailers/application_mailer.rb ================================================ class ApplicationMailer < ActionMailer::Base default from: ENV.fetch("MAILER_FROM_ADDRESS", "Fizzy ") layout "mailer" append_view_path Rails.root.join("app/views/mailers") helper AvatarsHelper, HtmlHelper private def default_url_options if Current.account super.merge(script_name: Current.account.slug) else super end end end ================================================ FILE: app/mailers/concerns/mailers/unsubscribable.rb ================================================ module Mailers::Unsubscribable extend ActiveSupport::Concern included do after_action :set_unsubscribe_headers end def set_unsubscribe_headers headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click" headers["List-Unsubscribe"] = "<#{notifications_unsubscribe_url(access_token: @unsubscribe_token)}>" end end ================================================ FILE: app/mailers/export_mailer.rb ================================================ class ExportMailer < ApplicationMailer helper_method :export_download_url def completed(export) @export = export @user = export.user mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" end private def export_download_url(export) if export.is_a?(User::DataExport) user_data_export_url(export.user, export) else account_export_url(export) end end end ================================================ FILE: app/mailers/import_mailer.rb ================================================ class ImportMailer < ApplicationMailer def completed(identity, account) @account = account mail to: identity.email_address, subject: "Your Fizzy account import is done" end def failed(import) @import = import mail to: import.identity.email_address, subject: "Your Fizzy account import failed" end end ================================================ FILE: app/mailers/magic_link_mailer.rb ================================================ class MagicLinkMailer < ApplicationMailer def sign_in_instructions(magic_link) @magic_link = magic_link @identity = @magic_link.identity mail to: @identity.email_address, subject: "Your Fizzy code is #{ @magic_link.code }" end end ================================================ FILE: app/mailers/notification/bundle_mailer.rb ================================================ class Notification::BundleMailer < ApplicationMailer include Mailers::Unsubscribable helper NotificationsHelper def notification(bundle) @user = bundle.user @bundle = bundle @notifications = bundle.notifications .preload(:card, :creator, source: [ :board, :creator ]) .reject { |n| n.source.nil? || n.card.nil? } @unsubscribe_token = @user.generate_token_for(:unsubscribe) if @notifications.any? mail \ to: bundle.user.identity.email_address, subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications" end end end ================================================ FILE: app/mailers/user_mailer.rb ================================================ class UserMailer < ApplicationMailer def email_change_confirmation(email_address:, token:, user:) @token = token @user = user mail to: email_address, subject: "Confirm your new email address" end end ================================================ FILE: app/models/access.rb ================================================ class Access < ApplicationRecord belongs_to :account, default: -> { user.account } belongs_to :board, touch: true belongs_to :user, touch: true enum :involvement, %i[ access_only watching ].index_by(&:itself), default: :access_only scope :ordered_by_recently_accessed, -> { order(accessed_at: :desc) } after_destroy_commit :clean_inaccessible_data_later def accessed touch :accessed_at unless recently_accessed? end private def recently_accessed? accessed_at&.> 5.minutes.ago end def clean_inaccessible_data_later Board::CleanInaccessibleDataJob.perform_later(user, board) end end ================================================ FILE: app/models/account/cancellable.rb ================================================ module Account::Cancellable extend ActiveSupport::Concern included do has_one :cancellation, dependent: :destroy define_callbacks :cancel define_callbacks :reactivate end def cancel(initiated_by: Current.user) with_lock do if cancellable? && active? run_callbacks :cancel do create_cancellation!(initiated_by: initiated_by) end AccountMailer.cancellation(cancellation).deliver_later end end end def reactivate with_lock do if cancelled? run_callbacks :reactivate do cancellation.destroy end end end end def cancelled? cancellation.present? end def cancellable? Account.accepting_signups? end end ================================================ FILE: app/models/account/cancellation.rb ================================================ class Account::Cancellation < ApplicationRecord belongs_to :account belongs_to :initiated_by, class_name: "User" end ================================================ FILE: app/models/account/data_transfer/account_record_set.rb ================================================ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet ACCOUNT_ATTRIBUTES = %w[ join_code name ] JOIN_CODE_ATTRIBUTES = %w[ code usage_count usage_limit ] def initialize(account) super(account: account, model: Account) end private def records [ account ] end def export_record(account) zip.add_file "data/account.json", account.as_json.merge(join_code: account.join_code.as_json).to_json end def files [ "data/account.json" ] end def import_batch(files) account_data = load(files.first) join_code_data = account_data.delete("join_code") account.update!(name: account_data.fetch("name"), cards_count: account_data.fetch("cards_count", 0)) account.join_code.update!(join_code_data.slice("usage_count", "usage_limit")) account.join_code.update(code: join_code_data.fetch("code")) end def check_record(file_path) data = load(file_path) unless (ACCOUNT_ATTRIBUTES - data.keys).empty? raise IntegrityError, "Account record missing required fields" end unless data.key?("join_code") raise IntegrityError, "Account record missing 'join_code' field" end unless data["join_code"].is_a?(Hash) raise IntegrityError, "'join_code' field must be a JSON object" end unless (JOIN_CODE_ATTRIBUTES - data["join_code"].keys).empty? raise IntegrityError, "'join_code' field missing required keys" end end end ================================================ FILE: app/models/account/data_transfer/action_text/rich_text_record_set.rb ================================================ class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet ATTRIBUTES = %w[ account_id body created_at id name record_id record_type updated_at ].freeze def initialize(account) super(account: account, model: ::ActionText::RichText) end private def records ::ActionText::RichText.where(account: account) end def export_record(rich_text) data = rich_text.as_json.merge("body" => transform_body_for_export(rich_text.body)) zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json end def files zip.glob("data/action_text_rich_texts/*.json") end def import_batch(files) batch_data = files.map do |file| data = load(file) data["body"] = transform_body_for_import(data["body"]) data.slice(*ATTRIBUTES).merge("account_id" => account.id) end ::ActionText::RichText.insert_all!(batch_data) end def check_record(file_path) data = load(file_path) expected_id = File.basename(file_path, ".json") unless data["id"].to_s == expected_id raise IntegrityError, "ActionTextRichText record ID mismatch: expected #{expected_id}, got #{data['id']}" end missing = ATTRIBUTES - data.keys if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end check_associations_dont_exist(data) end def transform_body_for_export(content) return nil if content.blank? html = convert_sgids_to_gids(content) relativize_urls(html) end def convert_sgids_to_gids(content) content.send(:attachment_nodes).each do |node| sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME) record = begin sgid&.find rescue ActiveRecord::RecordNotFound nil end if record&.account_id == account.id node["gid"] = record.to_global_id.to_s node.remove_attribute("sgid") end end content.fragment.source.to_html end def relativize_urls(html) host = Rails.application.routes.default_url_options[:host] return html unless host fragment = Nokogiri::HTML.fragment(html) fragment.css("a[href]").each do |link| uri = URI.parse(link["href"]) rescue nil if uri.respond_to?(:host) && uri.host == host link["href"] = uri.path link["href"] += "?#{uri.query}" if uri.query link["href"] += "##{uri.fragment}" if uri.fragment end end fragment.to_html end def transform_body_for_import(body) return body if body.blank? Nokogiri::HTML.fragment(body) .then { convert_gids_to_sgids(it) } .then { replace_account_slugs(it) } .to_html end def convert_gids_to_sgids(fragment) fragment.css("action-text-attachment[gid]").each do |node| gid = GlobalID.parse(node["gid"]) if gid record = begin gid.find rescue ActiveRecord::RecordNotFound nil end if record&.account_id == account.id node["sgid"] = record.attachable_sgid node.remove_attribute("gid") end end end fragment end def replace_account_slugs(fragment) fragment.css("a[href]").each do |link| match = link["href"].match(AccountSlug::PATH_INFO_MATCH) if match path = match.post_match.presence || "/" valid_path = Rails.application.routes.recognize_path(path) rescue nil link["href"] = "#{account.slug}#{path}" if valid_path end end fragment end end ================================================ FILE: app/models/account/data_transfer/active_storage/attachment_record_set.rb ================================================ class Account::DataTransfer::ActiveStorage::AttachmentRecordSet < Account::DataTransfer::RecordSet def initialize(account) super(account: account, model: ::ActiveStorage::Attachment) end private def records ::ActiveStorage::Attachment.where(account: account) .where.not(record_type: INTERNAL_RECORD_TYPES) end end ================================================ FILE: app/models/account/data_transfer/active_storage/blob_record_set.rb ================================================ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet def initialize(account) super( account: account, model: ::ActiveStorage::Blob, attributes: ::ActiveStorage::Blob.column_names - %w[service_name] ) end private def records ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids) end def excluded_blob_ids ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id) end def import_batch(files) batch_data = files.map do |file| data = load(file) data.slice(*attributes).merge( "account_id" => account.id, "key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH), "service_name" => ::ActiveStorage::Blob.service.name ) end model.insert_all!(batch_data) end end ================================================ FILE: app/models/account/data_transfer/active_storage/file_record_set.rb ================================================ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet def initialize(account) super(account: account, model: ::ActiveStorage::Blob) end private def records ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids) end def excluded_blob_ids ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id) end def export_record(blob) if blob.service.exist?(blob.key) zip.add_file("storage/#{blob.key}", compress: false) do |out| blob.download { |chunk| out.write(chunk) } end end end def files zip.glob("storage/*") end def import_batch(files) files.each do |file| old_key = file.delete_prefix("storage/") blob_id = old_key_to_blob_id[old_key] raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account) raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob zip.read(file) do |stream| blob.upload(stream) end end end def old_key_to_blob_id @old_key_to_blob_id ||= build_old_key_to_blob_id end def build_old_key_to_blob_id zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map| data = load(file) old_key = data["key"] if map.key?(old_key) raise IntegrityError, "Duplicate blob key in export: #{old_key}" end map[old_key] = data["id"] end end def with_zip(zip) @old_key_to_blob_id = nil super end def check_record(file_path) old_key = file_path.delete_prefix("storage/") unless old_key_to_blob_id.key?(old_key) raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" end end end ================================================ FILE: app/models/account/data_transfer/entropy_record_set.rb ================================================ class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet def initialize(account) super(account: account, model: Entropy) end private def import_batch(files) batch_data = files.map do |file| data = load(file) data.slice(*attributes).merge("account_id" => account.id) end container_keys = batch_data.map { |d| [ d["container_type"], d["container_id"] ] } existing_containers = Entropy .where(account_id: account.id) .where(container_type: container_keys.map(&:first), container_id: container_keys.map(&:last)) .pluck(:container_type, :container_id) .to_set to_update, to_insert = batch_data.partition do |data| existing_containers.include?([ data["container_type"], data["container_id"] ]) end to_update.each do |data| Entropy .find_by(account_id: account.id, container_type: data["container_type"], container_id: data["container_id"]) .update!(data.slice("auto_postpone_period")) end Entropy.insert_all!(to_insert) if to_insert.any? end end ================================================ FILE: app/models/account/data_transfer/manifest.rb ================================================ class Account::DataTransfer::Manifest attr_reader :account def initialize(account) @account = account end def each_record_set(start: nil) raise ArgumentError, "No block given" unless block_given? started = start.nil? record_class, last_id = start if start record_sets.each do |record_set| if started yield record_set elsif record_set.model.name == record_class started = true yield record_set, last_id end end end private def record_sets [ Account::DataTransfer::AccountRecordSet.new(account), Account::DataTransfer::UserRecordSet.new(account), *build_record_sets( ::User::Settings, ::Tag, ::Board, ::Column ), Account::DataTransfer::EntropyRecordSet.new(account), *build_record_sets( ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step, ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow, ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention, ::Filter, ::Webhook::DelinquencyTracker, ::Event, ::Notification, ::Notification::Bundle, ::Webhook::Delivery ), Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account), Account::DataTransfer::ActiveStorage::AttachmentRecordSet.new(account), Account::DataTransfer::ActionText::RichTextRecordSet.new(account), Account::DataTransfer::ActiveStorage::FileRecordSet.new(account) ].then { set_importable_model_names(it) } end def build_record_sets(*models) models.map do |model| Account::DataTransfer::RecordSet.new(account: account, model: model) end end def set_importable_model_names(record_sets) model_names = record_sets.filter_map { |record_set| record_set.model&.name } record_sets.each { |record_set| record_set.importable_model_names = model_names } record_sets end end ================================================ FILE: app/models/account/data_transfer/record_set.rb ================================================ class Account::DataTransfer::RecordSet class IntegrityError < StandardError; end class ConflictError < IntegrityError; end IMPORT_BATCH_SIZE = 100 INTERNAL_RECORD_TYPES = %w[Export Account::Import].freeze attr_accessor :importable_model_names attr_reader :account, :model, :attributes def initialize(account:, model:, attributes: nil, importable_model_names: nil) @account = account @model = model @attributes = (attributes || model.column_names).map(&:to_s) @importable_model_names = importable_model_names || [ model.name ] end def export(to:, start: nil) with_zip(to) do block = lambda do |record| export_record(record) end records.respond_to?(:find_each) ? records.find_each(&block) : records.each(&block) end end def import(from:, start: nil, callback: nil) with_zip(from) do file_list = files file_list = skip_to(file_list, start) if start file_list.each_slice(IMPORT_BATCH_SIZE) do |file_batch| import_batch(file_batch) callback&.call(record_set: self, files: file_batch) end end end def check(from:, start: nil, callback: nil) with_zip(from) do file_list = files file_list = skip_to(file_list, start) if start file_list.each do |file_path| check_record(file_path) callback&.call(record_set: self, file: file_path) end end end private attr_reader :zip def with_zip(zip) old_zip = @zip @zip = zip yield ensure @zip = old_zip end def records model.where(account_id: account.id) end def export_record(record) zip.add_file "data/#{model_dir}/#{record.id}.json", record.to_json end def files zip.glob("data/#{model_dir}/*.json") end def import_batch(files) batch_data = files.map do |file| data = load(file) data.slice(*attributes).merge("account_id" => account.id).tap do |record_data| record_data["updated_at"] = Time.current if record_data.key?("updated_at") end end model.insert_all!(batch_data) end def check_record(file_path) data = load(file_path) expected_id = File.basename(file_path, ".json") unless data["id"].to_s == expected_id raise IntegrityError, "#{model} record ID mismatch: expected #{expected_id}, got #{data['id']}" end missing = attributes - data.keys if missing.any? raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}" end if model.exists?(id: data["id"]) raise ConflictError, "#{model} record with ID #{data['id']} already exists" end check_associations_dont_exist(data) end def check_associations_dont_exist(data) model.reflect_on_all_associations(:belongs_to).each do |association| foreign_key = association.foreign_key.to_s if associated_id = data[foreign_key] check_association_doesnt_exist(data, association, associated_id) end end end def check_association_doesnt_exist(data, association, associated_id) if association.polymorphic? type_column = association.foreign_type.to_s associated_class = verify_model_type(data[type_column]) else associated_class = association.klass end if associated_class.exists?(id: associated_id) raise ConflictError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}" end end def verify_model_type(type_name) if importable_model_names.include?(type_name) type_name.constantize else raise IntegrityError, "Unrecognized model type: #{type_name}" end end def skip_to(file_list, last_id) index = file_list.index(last_id) if index file_list[(index + 1)..] else file_list end end def load(file_path) JSON.parse(zip.read(file_path)) rescue ArgumentError => e raise IntegrityError, e.message end def model_dir model.table_name end end ================================================ FILE: app/models/account/data_transfer/user_record_set.rb ================================================ class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet ATTRIBUTES = %w[ id email_address name role active verified_at created_at updated_at ] def initialize(account) super(account: account, model: User) end private def records User.where(account: account) end def export_record(user) zip.add_file "data/users/#{user.id}.json", user.as_json.merge(email_address: user.identity&.email_address).to_json end def files zip.glob("data/users/*.json") end def import_batch(files) batch_data = files.map do |file| user_data = load(file) email_address = user_data.delete("email_address") identity = Identity.find_or_create_by!(email_address: email_address) if email_address.present? user_data.slice(*ATTRIBUTES).merge( "account_id" => account.id, "identity_id" => identity&.id ) end conflicting_identity_ids = batch_data.pluck("identity_id").compact account.users.where(identity_id: conflicting_identity_ids).destroy_all User.insert_all!(batch_data) end def check_record(file_path) data = load(file_path) expected_id = File.basename(file_path, ".json") unless data["id"].to_s == expected_id raise IntegrityError, "User record ID mismatch: expected #{expected_id}, got #{data['id']}" end unless (ATTRIBUTES - data.keys).empty? raise IntegrityError, "#{file_path} is missing required fields" end end end ================================================ FILE: app/models/account/entropic.rb ================================================ module Account::Entropic extend ActiveSupport::Concern DEFAULT_ENTROPY_PERIOD = 30.days included do has_one :entropy, as: :container, dependent: :destroy after_create -> { create_entropy!(auto_postpone_period: DEFAULT_ENTROPY_PERIOD, account: self) } end end ================================================ FILE: app/models/account/export.rb ================================================ class Account::Export < Export private def filename "fizzy-account-#{account_id}-export-#{id}.zip" end def populate_zip(zip) Account::DataTransfer::Manifest.new(account).each_record_set do |record_set| record_set.export(to: zip) end end end ================================================ FILE: app/models/account/external_id_sequence.rb ================================================ # Provides sequential IDs for +external_account_id+ when creating accounts without one. class Account::ExternalIdSequence < ApplicationRecord class << self def next with_lock do |sequence| sequence.increment!(:value).value end end def value first&.value || self.next end private def with_lock transaction do sequence = lock.first_or_create!(value: initial_value) yield sequence end end def initial_value Account.maximum(:external_account_id) || 0 end end end ================================================ FILE: app/models/account/import.rb ================================================ class Account::Import < ApplicationRecord broadcasts_refreshes belongs_to :account belongs_to :identity has_one_attached :file enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending enum :failure_reason, %w[ conflict invalid_export ].index_by(&:itself), prefix: :failed_due_to, scopes: false scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) } def self.cleanup expired.each(&:cleanup) end def process_later Account::DataImportJob.perform_later(self) end def check(start: nil, callback: nil) processing! ZipFile.read_from(file.blob) do |zip| Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id| record_set.check(from: zip, start: last_id, callback: callback) end end rescue Account::DataTransfer::RecordSet::ConflictError => e mark_as_failed(:conflict) raise e rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e mark_as_failed(:invalid_export) raise e rescue => e mark_as_failed raise e end def process(start: nil, callback: nil) processing! ZipFile.read_from(file.blob) do |zip| Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id| record_set.import(from: zip, start: last_id, callback: callback) end end add_importer_to_all_access_boards reconcile_cards_count reconcile_account_storage mark_completed rescue Account::DataTransfer::RecordSet::ConflictError => e mark_as_failed(:conflict) raise e rescue Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError => e mark_as_failed(:invalid_export) raise e rescue => e mark_as_failed raise e end def cleanup destroy account.destroy if failed? end private def mark_completed update!(status: :completed, completed_at: Time.current) ImportMailer.completed(identity, account).deliver_later end def mark_as_failed(failure_reason = nil) update!(status: :failed, failure_reason: failure_reason) ImportMailer.failed(self).deliver_later end def reconcile_cards_count account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max end def add_importer_to_all_access_boards importer = account.users.find_by!(identity: identity) account.boards.all_access.find_each do |board| board.accesses.grant_to(importer) end end def reconcile_account_storage account.boards.each(&:reconcile_storage) account.reconcile_storage account.materialize_storage end end ================================================ FILE: app/models/account/incineratable.rb ================================================ module Account::Incineratable extend ActiveSupport::Concern INCINERATION_GRACE_PERIOD = 30.days included do scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) } define_callbacks :incinerate end def incinerate run_callbacks :incinerate do account.destroy end end end ================================================ FILE: app/models/account/join_code.rb ================================================ class Account::JoinCode < ApplicationRecord CODE_LENGTH = 12 USAGE_LIMIT_MAX = 10_000_000_000 belongs_to :account validates :usage_limit, numericality: { less_than_or_equal_to: USAGE_LIMIT_MAX, message: "cannot be larger than the population of the planet" } scope :active, -> { where("usage_count < usage_limit") } before_create :generate_code, if: -> { code.blank? } def redeem_if(&block) with_lock do increment!(:usage_count) if active? && block.call(account) end end def active? usage_count < usage_limit end def reset generate_code self.usage_count = 0 save! end private def generate_code self.code = loop do candidate = SecureRandom.base58(CODE_LENGTH).scan(/.{4}/).join("-") break candidate unless self.class.exists?(code: candidate) end end end ================================================ FILE: app/models/account/multi_tenantable.rb ================================================ module Account::MultiTenantable extend ActiveSupport::Concern included do cattr_accessor :multi_tenant, default: false end class_methods do def accepting_signups? multi_tenant || Account.none? end end end ================================================ FILE: app/models/account/seedeable.rb ================================================ module Account::Seedeable extend ActiveSupport::Concern def setup_customer_template Account::Seeder.new(self, users.admin.first).seed end end ================================================ FILE: app/models/account/seeder.rb ================================================ class Account::Seeder attr_reader :account, :creator def initialize(account, creator) @account = account @creator = creator end def seed Current.set(user: creator, account: account) do populate end end def seed! raise "You can't run in production environments" unless Rails.env.local? delete_everything seed end private def populate # --------------- # Playground Board # --------------- playground = account.boards.create! name: "Playground", creator: creator, all_access: true playground.update! auto_postpone_period: 365.days # Cards playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML

There’s a whole lot more you can do in Fizzy. In the video below, 37signals founder and CEO, Jason Fried, will walk you through the basics in just 17 minutes.

HTML # TODO: Replace the video here with a screencap of creating a passkey playground.cards.create! creator: creator, title: "Then, set up a Passkey", status: "published", description: <<~HTML

Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “My Profile > Manage Passkeys”. Using a passkey is optional, but recommended.

HTML playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML

Open the Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.

HTML playground.cards.create! creator: creator, title: "Then, head back home to check out activity", status: "published", description: <<~HTML

Hit “1” or pull down the Fizzy menu and select “Home”.

HTML playground.cards.create! creator: creator, title: "Now, check out all cards assigned to you", status: "published", description: <<~HTML

Pull down the Fizzy menu at the top of the screen, and select “Assigned to me” or just hit “2” on your keyboard any time.

HTML playground.cards.create! creator: creator, title: "Then, open the Fizzy menu", status: "published", description: <<~HTML

The Fizzy menu is how you get around the app. Click “Fizzy” at the top of the screen or hit the “J” key on your keyboard to pop it open.

HTML playground.cards.create! creator: creator, title: "Next, assign this card to yourself", status: "published", description: <<~HTML

Click the little head with the + next to it, then pick yourself.

HTML playground.cards.create! creator: creator, title: "Now, tag this card “Design” then move it to YES", status: "published", description: <<~HTML

Click the little Tag icon, type “design”, then “Create tag”. Then, move the card to the new “YES” column you created in the previous step.

HTML playground.cards.create! creator: creator, title: "Next, make two more columns", status: "published", description: <<~HTML
  1. Make one called "Yes"
  2. Make another called "Working on"

Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.


After that, drag this card to “DONE” or select “DONE” in the sidebar.

HTML playground.cards.create! creator: creator, title: "Second, move this card to NOT NOW", status: "published", description: <<~HTML

You can either select “NOT NOW” over in the sidebar, or you can go back out to the board view and drag this card into the “NOT NOW” column on the left side.


HTML playground.cards.create! creator: creator, title: "First, rename this card", status: "published", description: <<~HTML
  1. Click the title and you can rename the card, change the description, or add more information to the card.
  2. Then, hit "Mark as Done" at the bottom of the card.
  3. Finally, hit “Back to Playground” in the top left of the screen to go back to the board.
HTML end def delete_everything Current.set(user: creator, account: account) do account.boards.destroy_all end end end ================================================ FILE: app/models/account/storage.rb ================================================ module Account::Storage extend ActiveSupport::Concern include Storage::Totaled private def calculate_real_storage_bytes boards.sum { |board| board.send(:calculate_real_storage_bytes) } end end ================================================ FILE: app/models/account.rb ================================================ class Account < ApplicationRecord include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable has_one :join_code, dependent: :destroy has_many :users, dependent: :destroy has_many :boards, dependent: :destroy has_many :cards, dependent: :destroy has_many :webhooks, dependent: :destroy has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy has_many :entropies, dependent: :destroy has_many :exports, class_name: "Account::Export", dependent: :destroy has_many :imports, class_name: "Account::Import", dependent: :destroy scope :importing, -> { left_joins(:imports).where(account_imports: { status: %i[pending processing failed] }) } scope :active, -> { where.missing(:cancellation).and(where.not(id: importing)) } before_create :assign_external_account_id after_create :create_join_code validates :name, presence: true class << self def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current)) end end end def slug "/#{AccountSlug.encode(external_account_id)}" end def account self end def system_user users.find_by!(role: :system) end def active? !cancelled? && !importing? end def importing? imports.where(status: %i[pending processing failed]).exists? end private def assign_external_account_id self.external_account_id ||= ExternalIdSequence.next end end ================================================ FILE: app/models/admin.rb ================================================ module Admin end ================================================ FILE: app/models/application_platform.rb ================================================ class ApplicationPlatform < PlatformAgent def ios? match? /iPhone|iPad/ end def android? match? /Android/ end def mac? match? /Macintosh/ end def chrome? user_agent.browser.match? /Chrome/ end def edge? user_agent.browser.match? /Edg/ end def firefox? user_agent.browser.match? /Firefox|FxiOS/ end def safari? user_agent.browser.match? /Safari/ end def mobile? ios? || android? end def desktop? !mobile? end def native? match? /Hotwire Native/ end def windows? operating_system == "Windows" end def bridge_name case when native? && android? then :android when native? && ios? then :ios end end def bridge_components extract_list_from_native_user_agent("bridge-components") end def type if native? && android? "native android" elsif native? && ios? "native ios" elsif mobile? "mobile web" else "desktop web" end end def operating_system case user_agent.platform when /Android/ then "Android" when /iPad/ then "iPad" when /iPhone/ then "iPhone" when /Macintosh/ then "macOS" when /Windows/ then "Windows" when /CrOS/ then "ChromeOS" else os =~ /Linux/ ? "Linux" : os end end private def extract_list_from_native_user_agent(prefix) if native? user_agent.to_s.match(/#{Regexp.escape(prefix)}: \[(.*?)\]/) { |matches| matches[1] }.to_s else "" end end end ================================================ FILE: app/models/application_record.rb ================================================ class ApplicationRecord < ActiveRecord::Base primary_abstract_class configure_replica_connections end ================================================ FILE: app/models/assignment.rb ================================================ class Assignment < ApplicationRecord LIMIT = 100 belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :assignee, class_name: "User" belongs_to :assigner, class_name: "User" validate :within_limit, on: :create private def within_limit if card.assignments.count >= LIMIT errors.add(:base, "Card already has the maximum of #{LIMIT} assignees") end end end ================================================ FILE: app/models/board/accessible.rb ================================================ module Board::Accessible extend ActiveSupport::Concern included do has_many :accesses, dependent: :delete_all do def revise(granted: [], revoked: []) transaction do grant_to granted revoke_from revoked end end def grant_to(users) Access.insert_all Array(users).collect { |user| { id: ActiveRecord::Type::Uuid.generate, board_id: proxy_association.owner.id, user_id: user.id, account_id: proxy_association.owner.account.id } } end def revoke_from(users) destroy_by user: users unless proxy_association.owner.all_access? end end has_many :users, through: :accesses has_many :access_only_users, -> { merge(Access.access_only) }, through: :accesses, source: :user scope :all_access, -> { where(all_access: true) } after_create :grant_access_to_creator after_save_commit :grant_access_to_everyone end def accessed_by(user) access_for(user).accessed end def access_for(user) accesses.find_by(user: user) end def accessible_to?(user) access_for(user).present? end def clean_inaccessible_data_for(user) return if accessible_to?(user) mentions_for_user(user).destroy_all notifications_for_user(user).destroy_all watches_for(user).destroy_all pins_for(user).destroy_all end def watchers users.active.where(accesses: { involvement: :watching }) end private def grant_access_to_creator accesses.create(user: creator, involvement: :watching) end def grant_access_to_everyone accesses.grant_to(account.users.active) if all_access_previously_changed?(to: true) end def mentions_for_user(user) # Query handles 2 paths: # # 1. Mention->Card # 2. Mention->Comment->Card board_id_binary = ActiveRecord::Type::Uuid.new.serialize(id) user.mentions .joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'") .joins("LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'") .joins("LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id") .where("(mentions.source_type = 'Card' AND cards.board_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.board_id = ?)", board_id_binary, board_id_binary) end def notifications_for_user(user) # Query handles 2 paths: # # 1. Notification->Event->Card # 2. Notification->Event->Comment->Card # # Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are # handled by destroying mentions_for_user. uuid_type = ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) board_id_binary = uuid_type.serialize(id) user.notifications .joins("LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'") .joins("LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'") .joins("LEFT JOIN comments AS event_comments ON events.eventable_id = event_comments.id AND events.eventable_type = 'Comment'") .joins("LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id") .where("(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.board_id = ?) OR (notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.board_id = ?)", board_id_binary, board_id_binary) end def watches_for(user) Watch.where(card: cards, user: user) end def pins_for(user) Pin.where(card: cards, user: user) end end ================================================ FILE: app/models/board/auto_postponing.rb ================================================ module Board::AutoPostponing extend ActiveSupport::Concern included do before_create :set_default_auto_postpone_period end private def set_default_auto_postpone_period self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period) end end ================================================ FILE: app/models/board/broadcastable.rb ================================================ module Board::Broadcastable extend ActiveSupport::Concern included do broadcasts_refreshes broadcasts_refreshes_to ->(board) { [ board.account, :all_boards ] } end end ================================================ FILE: app/models/board/cards.rb ================================================ module Board::Cards extend ActiveSupport::Concern included do has_many :cards, dependent: :destroy after_update_commit -> { cards.touch_all }, if: :saved_change_to_name? end end ================================================ FILE: app/models/board/entropic.rb ================================================ module Board::Entropic extend ActiveSupport::Concern included do delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy has_one :entropy, as: :container, dependent: :destroy end def entropy super || account.entropy end def auto_postpone_period=(new_value) entropy ||= association(:entropy).reader || self.build_entropy entropy.update! auto_postpone_period: new_value end def auto_postpone_period_in_days=(value) self.auto_postpone_period = value.to_i.days.to_i end end ================================================ FILE: app/models/board/publication.rb ================================================ class Board::Publication < ApplicationRecord belongs_to :account, default: -> { board.account } belongs_to :board, touch: true has_secure_token :key end ================================================ FILE: app/models/board/publishable.rb ================================================ module Board::Publishable extend ActiveSupport::Concern included do has_one :publication, class_name: "Board::Publication", dependent: :destroy scope :published, -> { joins(:publication) } end class_methods do def find_by_published_key(key) Board::Publication.find_by!(key: key).board end end def published? publication.present? end alias_method :publicly_accessible?, :published? def publish create_publication! unless published? end def unpublish publication&.destroy end end ================================================ FILE: app/models/board/storage.rb ================================================ module Board::Storage extend ActiveSupport::Concern include Storage::Totaled # Board's own embeds (public_description) count toward itself def board_for_storage_tracking self end private BATCH_SIZE = 1000 # Calculate actual storage by summing blob sizes. # # Storage tracking is a business abstraction - we count what users upload. # Original upload bytes only; variants/previews/derivatives excluded. # Physical storage optimizations (deduplication, compression) don't affect quotas. def calculate_real_storage_bytes @card_ids = nil # Clear memoization for fresh calculation card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes end def card_ids @card_ids ||= cards.ids end def card_image_bytes sum_blob_bytes_in_batches \ ActiveStorage::Attachment.where(record_type: "Card", name: "image"), card_ids end def card_embed_bytes sum_embed_bytes_for "Card", card_ids end def comment_embed_bytes card_ids.each_slice(BATCH_SIZE).sum do |batch| sum_embed_bytes_for "Comment", Comment.where(card_id: batch).ids end end def board_embed_bytes sum_embed_bytes_for "Board", [ id ] end def sum_embed_bytes_for(record_type, record_ids) rich_text_ids = ActionText::RichText \ .where(record_type: record_type, record_id: record_ids).ids sum_blob_bytes_in_batches \ ActiveStorage::Attachment.where(record_type: "ActionText::RichText", name: "embeds"), rich_text_ids end def sum_blob_bytes_in_batches(base_scope, record_ids) # Count per-attachment to match ledger model. # Same blob attached 3 times = 3x bytes (business abstraction, not physical storage). # # Do NOT remove the join thinking it's a performance optimization - it's required # for correct per-attachment counting. We keep ActiveStorage/ActionText in the same # database (realm/geo partitioning, not functionality partitioning), so cross-table # joins are fine. record_ids.each_slice(BATCH_SIZE).sum do |batch_ids| base_scope .where(record_id: batch_ids) .joins(:blob) .sum("active_storage_blobs.byte_size") end end end ================================================ FILE: app/models/board/triageable.rb ================================================ module Board::Triageable extend ActiveSupport::Concern included do has_many :columns, dependent: :destroy end end ================================================ FILE: app/models/board.rb ================================================ class Board < ApplicationRecord include Accessible, AutoPostponing, Board::Storage, Broadcastable, Cards, Entropic, Filterable, Publishable, ::Storage::Tracked, Triageable belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :account, default: -> { creator.account } has_rich_text :public_description has_many :tags, -> { distinct }, through: :cards has_many :events has_many :webhooks, dependent: :destroy scope :alphabetically, -> { order("lower(name)") } scope :ordered_by_recently_accessed, -> { merge(Access.ordered_by_recently_accessed) } end ================================================ FILE: app/models/card/accessible.rb ================================================ module Card::Accessible extend ActiveSupport::Concern included do delegate :accessible_to?, to: :board end def publicly_accessible? published? && board.publicly_accessible? end def clean_inaccessible_data accessible_user_ids = board.accesses.pluck(:user_id) pins.where.not(user_id: accessible_user_ids).in_batches.destroy_all watches.where.not(user_id: accessible_user_ids).in_batches.destroy_all end private def grant_access_to_assignees board.accesses.grant_to(assignees) end def clean_inaccessible_data_later Card::CleanInaccessibleDataJob.perform_later(self) end end ================================================ FILE: app/models/card/activity_spike/detector.rb ================================================ class Card::ActivitySpike::Detector attr_reader :card def initialize(card) @card = card end def detect if has_activity_spike? register_activity_spike true else false end end private def has_activity_spike? card.entropic? && (multiple_people_commented? || card_was_just_assigned? || card_was_just_reopened?) end def register_activity_spike Card.suppressing_turbo_broadcasts do Card::ActivitySpike.find_or_create_by!(card: card).touch end end def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2) card.comments .where(created_at: recent_period.seconds.ago..) .group(:card_id) .having("COUNT(*) >= ?", minimum_comments) .having("COUNT(DISTINCT creator_id) >= ?", minimum_participants) .exists? end def recent_period card.entropy.auto_clean_period * 0.33 end def card_was_just_assigned? card.assigned? && card_was_just?(:assigned) end def card_was_just_reopened? card.open? && card_was_just?(:reopened) end def card_was_just?(action) last_event&.action&.to_s == "card_#{action}" && last_event.created_at > 1.minute.ago end def last_event card.events.order(:created_at).last end end ================================================ FILE: app/models/card/activity_spike.rb ================================================ class Card::ActivitySpike < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true end ================================================ FILE: app/models/card/assignable.rb ================================================ module Card::Assignable extend ActiveSupport::Concern included do has_many :assignments, dependent: :delete_all has_many :assignees, through: :assignments scope :unassigned, -> { where.missing :assignments } scope :assigned_to, ->(users) { joins(:assignments).where(assignments: { assignee: users }).distinct } scope :assigned_by, ->(users) { joins(:assignments).where(assignments: { assigner: users }).distinct } end def toggle_assignment(user) assigned_to?(user) ? unassign(user) : assign(user) end def assigned_to?(user) assignments.any? { |a| a.assignee_id == user.id } end def assigned? assignments.any? end private def assign(user) assignment = assignments.create assignee: user, assigner: Current.user if assignment.persisted? watch_by user track_event :assigned, assignee_ids: [ user.id ] end rescue ActiveRecord::RecordNotUnique # Already assigned end def unassign(user) destructions = assignments.destroy_by assignee: user track_event :unassigned, assignee_ids: [ user.id ] if destructions.any? end end ================================================ FILE: app/models/card/broadcastable.rb ================================================ module Card::Broadcastable extend ActiveSupport::Concern included do broadcasts_refreshes before_update :remember_if_preview_changed end private def remember_if_preview_changed @preview_changed ||= title_changed? || column_id_changed? || board_id_changed? end def preview_changed? @preview_changed end end ================================================ FILE: app/models/card/closeable.rb ================================================ module Card::Closeable extend ActiveSupport::Concern included do has_one :closure, dependent: :destroy scope :closed, -> { joins(:closure) } scope :open, -> { where.missing(:closure) } scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) } scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) } scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) } end def closed? closure.present? end def open? !closed? end def closed_by closure&.user end def closed_at closure&.created_at end def close(user: Current.user) unless closed? transaction do not_now&.destroy create_closure! user: user track_event :closed, creator: user end end end def reopen(user: Current.user) if closed? transaction do closure&.destroy track_event :reopened, creator: user end end end end ================================================ FILE: app/models/card/colored.rb ================================================ module Card::Colored extend ActiveSupport::Concern def color column&.color || Column::Colored::DEFAULT_COLOR end end ================================================ FILE: app/models/card/commentable.rb ================================================ module Card::Commentable extend ActiveSupport::Concern included do has_many :comments, dependent: :destroy end def commentable? published? end private STORAGE_BATCH_SIZE = 1000 # Override to include comments, but only load comments that have attachments. # Cards can have thousands of comments; most won't have attachments. # Streams in batches to avoid loading all IDs into memory at once. def storage_transfer_records comment_ids_with_attachments = storage_comment_ids_with_attachments if comment_ids_with_attachments.any? [ self, *comments.where(id: comment_ids_with_attachments).to_a ] else [ self ] end end def storage_comment_ids_with_attachments direct = [] rich_text_map = {} # Stream comment IDs in batches to avoid loading all into memory comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| batch_ids = batch.pluck(:id) direct.concat \ ActiveStorage::Attachment .where(record_type: "Comment", record_id: batch_ids) .distinct .pluck(:record_id) ActionText::RichText .where(record_type: "Comment", record_id: batch_ids) .pluck(:id, :record_id) .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } end embed_comment_ids = if rich_text_map.any? rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| ActiveStorage::Attachment .where(record_type: "ActionText::RichText", record_id: batch_ids) .distinct .pluck(:record_id) end.filter_map { |rt_id| rich_text_map[rt_id] } else [] end (direct + embed_comment_ids).uniq end end ================================================ FILE: app/models/card/entropic.rb ================================================ module Card::Entropic extend ActiveSupport::Concern included do scope :due_to_be_postponed, -> do active .joins(board: :account) .left_outer_joins(board: :entropy) .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") .where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", Time.now) end scope :postponing_soon, -> do now = Time.now active .joins(board: :account) .left_outer_joins(board: :entropy) .joins("LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id") .where("last_active_at > #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)')}", now) .where("last_active_at <= #{connection.date_subtract('?', 'COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period) * 0.75')}", now) end delegate :auto_postpone_period, to: :board end class_methods do def auto_postpone_all_due due_to_be_postponed.find_each do |card| card.auto_postpone(user: card.account.system_user) end end end def entropy Card::Entropy.for(self) end def entropic? entropy.present? end end ================================================ FILE: app/models/card/entropy.rb ================================================ class Card::Entropy attr_reader :card, :auto_clean_period class << self def for(card) return unless card.last_active_at new(card, card.auto_postpone_period) end end def initialize(card, auto_clean_period) @card = card @auto_clean_period = auto_clean_period end def auto_clean_at card.last_active_at + auto_clean_period end def days_before_reminder (auto_clean_period * 0.25).seconds.in_days.round end end ================================================ FILE: app/models/card/eventable/system_commenter.rb ================================================ class Card::Eventable::SystemCommenter include ERB::Util attr_reader :card, :event def initialize(card, event) @card, @event = card, event end def comment return unless comment_body.present? card.comments.create! creator: card.account.system_user, body: comment_body, created_at: event.created_at end private def comment_body case event.action when "card_assigned" "#{creator_name} assigned this to #{assignee_names}." when "card_unassigned" "#{creator_name} unassigned from #{assignee_names}." when "card_closed" "Moved to “Done” by #{creator_name}" when "card_reopened" "Reopened by #{creator_name}" when "card_postponed" "#{creator_name} moved this to “Not Now”" when "card_auto_postponed" "Moved to “Not Now” due to inactivity" when "card_title_changed" "#{creator_name} changed the title from “#{old_title}” to “#{new_title}”." when "card_board_changed" "#{creator_name} moved this from “#{old_board}” to “#{new_board}”." when "card_triaged" "#{creator_name} moved this to “#{column}”" when "card_sent_back_to_triage" "#{creator_name} moved this back to “Maybe?”" end end def creator_name h event.creator.name end def assignee_names h event.assignees.pluck(:name).to_sentence end def old_title h event.particulars.dig("particulars", "old_title") end def new_title h event.particulars.dig("particulars", "new_title") end def old_board h event.particulars.dig("particulars", "old_board") end def new_board h event.particulars.dig("particulars", "new_board") end def column h event.particulars.dig("particulars", "column") end end ================================================ FILE: app/models/card/eventable.rb ================================================ module Card::Eventable extend ActiveSupport::Concern include ::Eventable included do before_create { self.last_active_at ||= created_at || Time.current } after_save :track_title_change, if: :saved_change_to_title? end def event_was_created(event) transaction do create_system_comment_for(event) touch_last_active_at unless was_just_published? end end def touch_last_active_at # Not using touch so that we can detect attribute change on callbacks update!(last_active_at: Time.current) end private def should_track_event? published? end def track_title_change if title_before_last_save.present? track_event "title_changed", particulars: { old_title: title_before_last_save, new_title: title } end end def create_system_comment_for(event) SystemCommenter.new(self, event).comment end end ================================================ FILE: app/models/card/exportable.rb ================================================ module Card::Exportable extend ActiveSupport::Concern include ActionView::Helpers::TagHelper def export_json JSON.pretty_generate({ number: number, title: title, board: board.name, status: export_status, creator: export_user(creator), description: export_html(description), created_at: created_at.iso8601, updated_at: updated_at.iso8601, comments: comments.chronologically.map do |comment| { id: comment.id, body: export_html(comment.body), creator: export_user(comment.creator), created_at: comment.created_at.iso8601 } end }) end def export_attachments collect_attachments.map do |attachment| { path: export_attachment_path(attachment.blob), blob: attachment.blob } end end private def export_html(rich_text) return "" if rich_text.blank? rich_text.body.render_attachments do |attachment| attachment_representation(attachment) end.to_html end def attachment_representation(attachment) case attachable = attachment.attachable when ActiveStorage::Blob path = export_attachment_path(attachable) if attachable.image? tag.img(src: path, alt: attachable.filename) else tag.a(attachable.filename, href: path) end when ActionText::Attachables::RemoteImage tag.img(src: attachable.url, alt: "Remote image") else attachment.to_html end end def export_user(user) { id: user.id, name: user.name, email: user.identity&.email_address } end def export_attachment_path(blob) "#{number}/#{blob.key}_#{blob.filename}" end def collect_attachments (attachments.to_a + comments.flat_map { |c| c.attachments.to_a }).uniq(&:blob_id) end def export_status case when closed? "Done" when postponed? "Not now" when column.present? column.name else "Maybe?" end end end ================================================ FILE: app/models/card/golden.rb ================================================ module Card::Golden extend ActiveSupport::Concern included do has_one :goldness, dependent: :destroy, class_name: "Card::Goldness" scope :golden, -> { joins(:goldness) } scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL").preload(:goldness) } end def golden? goldness.present? end def gild create_goldness! unless golden? end def ungild goldness&.destroy end end ================================================ FILE: app/models/card/goldness.rb ================================================ class Card::Goldness < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true end ================================================ FILE: app/models/card/mentions.rb ================================================ module Card::Mentions extend ActiveSupport::Concern included do include ::Mentions def mentionable? published? end def should_check_mentions? was_just_published? end end end ================================================ FILE: app/models/card/multistep.rb ================================================ module Card::Multistep extend ActiveSupport::Concern included do has_many :steps, dependent: :destroy end end ================================================ FILE: app/models/card/not_now.rb ================================================ class Card::NotNow < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, class_name: "::Card", touch: true belongs_to :user, optional: true end ================================================ FILE: app/models/card/pinnable.rb ================================================ module Card::Pinnable extend ActiveSupport::Concern included do has_many :pins, dependent: :destroy after_update_commit :broadcast_pin_updates, if: :preview_changed? end def pinned_by?(user) pins.exists?(user: user) end def pin_for(user) pins.find_by(user: user) end def pin_by(user) pins.find_or_create_by!(user: user) end def unpin_by(user) pins.find_by(user: user).tap { it.destroy } end private def broadcast_pin_updates pins.find_each do |pin| pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin" end end end ================================================ FILE: app/models/card/postponable.rb ================================================ module Card::Postponable extend ActiveSupport::Concern included do has_one :not_now, dependent: :destroy, class_name: "Card::NotNow" scope :postponed, -> { open.published.joins(:not_now) } scope :active, -> { open.published.where.missing(:not_now) } end def postponed? open? && published? && not_now.present? end def postponed_at not_now&.created_at end def postponed_by not_now&.user end def active? open? && published? && !postponed? end def auto_postpone(**args) postpone(**args, event_name: :auto_postponed) end def postpone(user: Current.user, event_name: :postponed) transaction do send_back_to_triage(skip_event: true) reopen activity_spike&.destroy create_not_now!(user: user) unless postponed? track_event event_name, creator: user end end def resume transaction do reopen activity_spike&.destroy not_now&.destroy end end end ================================================ FILE: app/models/card/promptable.rb ================================================ module Card::Promptable extend ActiveSupport::Concern included do include Rails.application.routes.url_helpers end def to_prompt <<~PROMPT BEGIN OF CARD #{id} **Title:** #{title.first(1000)} **Description:** #{description.to_plain_text.first(10_000)} #### Metadata * Id: #{id} * Created by: #{creator.name}} * Assigned to: #{assignees.map(&:name).join(", ")} * Column: #{column_prompt_label} * Created at: #{created_at}} * Board id: #{board_id} * Board name: #{board.name} * Number of comments: #{comments.count} * Path: #{card_path(self, script_name: account.slug)} END OF CARD #{id} PROMPT end private def column_prompt_label if open? if postponed? "Not now" elsif triaged? "#{column&.name}" else "Maybe?" end else "Closed (by #{closed_by&.name} at #{closed_at})" end end end ================================================ FILE: app/models/card/readable.rb ================================================ module Card::Readable extend ActiveSupport::Concern def read_by(user) user.notifications.find_by(card: self)&.tap(&:read) end def unread_by(user) user.notifications.find_by(card: self)&.tap(&:unread) end def remove_inaccessible_notifications accessible_user_ids = board.accesses.pluck(:user_id) notification_sources.each do |sources| inaccessible_notifications_from(sources, accessible_user_ids).in_batches.destroy_all end end private def remove_inaccessible_notifications_later Card::RemoveInaccessibleNotificationsJob.perform_later(self) end def event_notification_sources events.or(comment_creation_events) end def comment_creation_events Event.where(eventable: comments) end def inaccessible_notifications_from(sources, accessible_user_ids) Notification.where(source: sources).where.not(user_id: accessible_user_ids) end def notification_sources [ events, comment_creation_events, mentions, comment_mentions ] end def mention_notification_sources mentions.or(comment_mentions) end def comment_mentions Mention.where(source: comments) end end ================================================ FILE: app/models/card/searchable.rb ================================================ module Card::Searchable extend ActiveSupport::Concern included do include ::Searchable scope :mentioning, ->(query, user:) do search_record_class = Search::Record.for(user.account_id) joins(search_record_class.card_join).merge(search_record_class.for_query(query, user: user)) end end def search_title title end def search_content description.to_plain_text end def search_card_id id end def search_board_id board_id end def searchable? published? end end ================================================ FILE: app/models/card/stallable.rb ================================================ module Card::Stallable extend ActiveSupport::Concern STALLED_AFTER_LAST_SPIKE_PERIOD = 14.days included do has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy scope :with_activity_spikes, -> { joins(:activity_spike) } scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } before_update :remember_to_detect_activity_spikes after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? end # Keep in sync with #isStalled in app/javascript/controllers/bubble_controller.js def stalled? if activity_spike.present? open? && last_activity_spike_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago && updated_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago end end def last_activity_spike_at activity_spike&.updated_at end def detect_activity_spikes Card::ActivitySpike::Detector.new(self).detect end private def remember_to_detect_activity_spikes @should_detect_activity_spikes = published? && last_active_at_changed? end def should_detect_activity_spikes? @should_detect_activity_spikes end def detect_activity_spikes_later Card::ActivitySpike::DetectionJob.perform_later(self) end end ================================================ FILE: app/models/card/statuses.rb ================================================ module Card::Statuses extend ActiveSupport::Concern included do enum :status, %w[ drafted published ].index_by(&:itself) before_save :mark_if_just_published after_create -> { track_event :published }, if: :published? end attr_accessor :was_just_published alias_method :was_just_published?, :was_just_published def publish transaction do self.created_at = Time.current published! track_event :published end end private def mark_if_just_published self.was_just_published = true if published? && status_changed? end end ================================================ FILE: app/models/card/taggable.rb ================================================ module Card::Taggable extend ActiveSupport::Concern included do has_many :taggings, dependent: :destroy has_many :tags, through: :taggings scope :tagged_with, ->(tags) { joins(:taggings).where(taggings: { tag: tags }) } end def toggle_tag_with(title) tag = account.tags.find_or_create_by!(title: title) transaction do if tagged_with?(tag) taggings.destroy_by tag: tag else taggings.create tag: tag end end end def tagged_with?(tag) tags.include? tag end end ================================================ FILE: app/models/card/triageable.rb ================================================ module Card::Triageable extend ActiveSupport::Concern included do belongs_to :column, optional: true, touch: true scope :awaiting_triage, -> { active.where.missing(:column) } scope :triaged, -> { active.joins(:column) } end def triaged? active? && column.present? end def awaiting_triage? active? && !triaged? end def triage_into(column) raise "The column must belong to the card board" unless board == column.board transaction do resume update! column: column track_event "triaged", particulars: { column: column.name } end end def send_back_to_triage(skip_event: false) transaction do resume update! column: nil track_event "sent_back_to_triage" unless skip_event end end end ================================================ FILE: app/models/card/watchable.rb ================================================ module Card::Watchable extend ActiveSupport::Concern included do has_many :watches, dependent: :destroy has_many :watchers, -> { active.merge(Watch.watching) }, through: :watches, source: :user after_create :subscribe_creator end def watched_by?(user) watch_for(user)&.watching? end def watch_for(user) watches.find_by(user: user) end def watch_by(user) watches.where(user: user).first_or_create.update!(watching: true) end def unwatch_by(user) watches.where(user: user).first_or_create.update!(watching: false) end private def subscribe_creator # Avoid touching to not interfere with the abandon card detection system Card.no_touching do watch_by creator end end end ================================================ FILE: app/models/card.rb ================================================ class Card < ApplicationRecord include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable, Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_one_attached :image, dependent: :purge_later has_rich_text :description before_save :set_default_title, if: :published? before_create :assign_number after_save -> { board.touch }, if: :published? after_touch -> { board.touch }, if: :published? after_update :handle_board_change, if: :saved_change_to_board_id? scope :reverse_chronologically, -> { order created_at: :desc, id: :desc } scope :chronologically, -> { order created_at: :asc, id: :asc } scope :latest, -> { order last_active_at: :desc, id: :desc } scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } scope :indexed_by, ->(index) do case index when "stalled" then stalled when "postponing_soon" then postponing_soon when "closed" then closed when "not_now" then postponed.latest when "golden" then golden when "draft" then drafted else all end end scope :sorted_by, ->(sort) do case sort when "newest" then reverse_chronologically when "oldest" then chronologically when "latest" then latest else latest end end def card self end def to_param number.to_s end def move_to(new_board) transaction do card.update!(board: new_board) card.events.update_all(board_id: new_board.id) Event.where(eventable: card.comments).update_all(board_id: new_board.id) end end def filled? title.present? || description.present? end private def set_default_title self.title = "Untitled" if title.blank? end def handle_board_change old_board = account.boards.find_by(id: board_id_before_last_save) transaction do update! column: nil track_board_change_event(old_board.name) grant_access_to_assignees unless board.all_access? end remove_inaccessible_notifications_later clean_inaccessible_data_later end def track_board_change_event(old_board_name) track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name } end def assign_number self.number ||= account.increment!(:cards_count).cards_count end end ================================================ FILE: app/models/closure.rb ================================================ class Closure < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :user, optional: true end ================================================ FILE: app/models/color.rb ================================================ Color = Struct.new(:name, :value) class Color class << self def for_value(value) COLORS.find { |it| it.value == value } end end def to_s value end COLORS = { "Blue" => "var(--color-card-default)", "Gray" => "var(--color-card-1)", "Tan" => "var(--color-card-2)", "Yellow" => "var(--color-card-3)", "Lime" => "var(--color-card-4)", "Aqua" => "var(--color-card-5)", "Violet" => "var(--color-card-6)", "Purple" => "var(--color-card-7)", "Pink" => "var(--color-card-8)" }.collect { |name, value| new(name, value) }.freeze end ================================================ FILE: app/models/column/colored.rb ================================================ module Column::Colored extend ActiveSupport::Concern DEFAULT_COLOR = Color::COLORS.first included do before_validation -> { self[:color] ||= DEFAULT_COLOR.value } end def color Color.for_value(super) || DEFAULT_COLOR end end ================================================ FILE: app/models/column/positioned.rb ================================================ module Column::Positioned extend ActiveSupport::Concern included do scope :sorted, -> { order(position: :asc) } before_create :set_position end def move_left swap_position_with left_column end def move_right swap_position_with right_column end def left_column board.columns.where("position < ?", position).sorted.last end def right_column board.columns.where("position > ?", position).sorted.first end def leftmost? left_column.nil? end def rightmost? right_column.nil? end def adjacent_columns board.columns.where(id: [ left_column&.id, right_column&.id ].compact) end private def set_position max_position = board.columns.maximum(:position) || 0 self.position = max_position + 1 end def swap_position_with(other_column) return if other_column.nil? transaction do old_position = self.position self.update_column(:position, other_column.position) other_column.update_column(:position, old_position) end end end ================================================ FILE: app/models/column.rb ================================================ class Column < ApplicationRecord include Colored, Positioned belongs_to :account, default: -> { board.account } belongs_to :board, touch: true has_many :cards, dependent: :nullify after_save_commit -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? } after_destroy_commit -> { board.cards.touch_all } end ================================================ FILE: app/models/comment/eventable.rb ================================================ module Comment::Eventable extend ActiveSupport::Concern include ::Eventable included do after_create_commit :track_creation end def event_was_created(event) card.touch_last_active_at end private def should_track_event? !creator.system? end def track_creation track_event("created", board: card.board, creator: creator) end end ================================================ FILE: app/models/comment/mentions.rb ================================================ module Comment::Mentions extend ActiveSupport::Concern included do include ::Mentions def mentionable? card.published? end end end ================================================ FILE: app/models/comment/promptable.rb ================================================ module Comment::Promptable extend ActiveSupport::Concern included do include Rails.application.routes.url_helpers end def to_prompt <<~PROMPT BEGIN OF COMMENT #{id} **Content:** #{body.to_plain_text.first(5000)} #### Metadata * Id: #{id} * Card id: #{card.number} * Card title: #{card.title} * Created by: #{creator.name}} * Created at: #{created_at}} * Path: #{card_path(card, anchor: ActionView::RecordIdentifier.dom_id(self), script_name: account.slug)} END OF COMMENT #{id} PROMPT end end ================================================ FILE: app/models/comment/searchable.rb ================================================ module Comment::Searchable extend ActiveSupport::Concern included do include ::Searchable end def search_title nil end def search_content body.to_plain_text end def search_card_id card_id end def search_board_id card.board_id end def searchable? card.published? end end ================================================ FILE: app/models/comment.rb ================================================ class Comment < ApplicationRecord include Attachments, Eventable, Mentions, Promptable, Searchable, Storage::Tracked belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_rich_text :body validate :card_is_commentable scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) } after_create_commit :watch_card_by_creator delegate :publicly_accessible?, :accessible_to?, :board, :watch_by, to: :card def to_partial_path "cards/#{super}" end private def card_is_commentable errors.add(:card, "does not allow comments") unless card.commentable? end def watch_card_by_creator card.watch_by creator end end ================================================ FILE: app/models/concerns/attachments.rb ================================================ module Attachments extend ActiveSupport::Concern # Variants used by ActionText embeds. Processed immediately on attachment to avoid # read replica issues (lazy variants would attempt writes on read replicas). # # Patched into ActionText::RichText in config/initializers/action_text.rb VARIANTS = { # vipsthumbnail used to create sized image variants has a intent setting to manage colors during # resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and # the gif can be rendered with all its frame intact. # # Only `n` is accepted as an override, using the full parameter name `intent` doesn’t work. # # This was cargo-culted from know-it-all and I imagine it may be fixed at some point. small: { loader: { n: -1 }, resize_to_limit: [ 800, 600 ] }, large: { loader: { n: -1 }, resize_to_limit: [ 1024, 768 ] } } def attachments rich_text_record&.embeds || [] end def has_attachments? attachments.any? end def remote_images @remote_images ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteImage) || [] end def has_remote_images? remote_images.any? end def remote_videos @remote_videos ||= rich_text_record&.body&.attachables&.grep(ActionText::Attachables::RemoteVideo) || [] end def has_remote_videos? remote_videos.any? end private def rich_text_record @rich_text_record ||= begin association = self.class.reflect_on_all_associations(:has_one).find { it.klass == ActionText::RichText } public_send(association.name) end end end ================================================ FILE: app/models/concerns/eventable.rb ================================================ module Eventable extend ActiveSupport::Concern included do has_many :events, as: :eventable, dependent: :destroy end def track_event(action, creator: Current.user, board: self.board, **particulars) if should_track_event? board.events.create!(action: "#{eventable_prefix}_#{action}", creator:, board:, eventable: self, particulars:) end end def event_was_created(event) end private def should_track_event? true end def eventable_prefix self.class.name.demodulize.underscore end end ================================================ FILE: app/models/concerns/filterable.rb ================================================ module Filterable extend ActiveSupport::Concern included do has_and_belongs_to_many :filters after_update { filters.touch_all } before_destroy :remove_from_filters end private # FIXME: This is too inefficient to have part of a destroy transaction. # Need to find a way to use a job or a single query. def remove_from_filters filters.each { it.resource_removed self } end end ================================================ FILE: app/models/concerns/mentions.rb ================================================ module Mentions extend ActiveSupport::Concern included do has_many :mentions, as: :source, dependent: :destroy has_many :mentionees, through: :mentions after_save_commit :create_mentions_later, if: :should_create_mentions? end def create_mentions(mentioner: Current.user) scan_mentionees.each do |mentionee| mentionee.mentioned_by mentioner, at: self end end def mentionable_content rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ") end def scan_mentionees mentionees_from_attachments & mentionable_users end private def mentionees_from_attachments rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact end def mentionable_users board.users end def rich_text_associations self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText } end def should_create_mentions? mentionable? && (mentionable_content_changed? || should_check_mentions?) end def mentionable_content_changed? rich_text_associations.any? { send(it.name)&.body_previously_changed? } end def create_mentions_later Mention::CreateJob.perform_later(self, mentioner: Current.user) end # Template method def mentionable? true end def should_check_mentions? false end end ================================================ FILE: app/models/concerns/notifiable.rb ================================================ module Notifiable extend ActiveSupport::Concern included do has_many :notifications, as: :source, dependent: :destroy after_create_commit :notify_recipients_later end def notify_recipients Notifier.for(self)&.notify end def notifiable_target self end private def notify_recipients_later NotifyRecipientsJob.perform_later self end end ================================================ FILE: app/models/concerns/searchable.rb ================================================ module Searchable extend ActiveSupport::Concern SEARCH_CONTENT_LIMIT = 32.kilobytes included do after_create_commit :create_in_search_index after_update_commit :update_in_search_index after_destroy_commit :remove_from_search_index end def reindex update_in_search_index end private def create_in_search_index if searchable? search_record_class.create!(search_record_attributes) end end def update_in_search_index if searchable? search_record_class.upsert!(search_record_attributes) else remove_from_search_index end end def remove_from_search_index search_record_class.find_by(searchable_type: self.class.name, searchable_id: id)&.destroy end def search_record_attributes { account_id: account_id, searchable_type: self.class.name, searchable_id: id, card_id: search_card_id, board_id: search_board_id, title: search_title, content: search_record_content, created_at: created_at } end def search_record_content search_content&.truncate_bytes(SEARCH_CONTENT_LIMIT, omission: "") end def search_record_class Search::Record.for(account_id) end # Models must implement these methods: # - account_id: returns the account id # - search_title: returns title string or nil # - search_content: returns content string # - search_card_id: returns the card id (self.id for cards, card_id for comments) # - search_board_id: returns the board id # - searchable?: returns whether this record should be indexed end ================================================ FILE: app/models/concerns/storage/totaled.rb ================================================ module Storage::Totaled extend ActiveSupport::Concern included do has_one :storage_total, as: :owner, class_name: "Storage::Total", dependent: :destroy has_many :storage_entries, class_name: "Storage::Entry", foreign_key: foreign_key_for_storage end class_methods do def foreign_key_for_storage "#{model_name.singular}_id" end end # Fast: materialized snapshot (may be slightly stale) def bytes_used storage_total&.bytes_stored || 0 end # Exact: snapshot + pending entries def bytes_used_exact create_or_find_storage_total.current_usage end def materialize_storage_later Storage::MaterializeJob.perform_later(self) end # Materialize all pending entries into snapshot def materialize_storage total = create_or_find_storage_total total.with_lock do latest_entry_id = storage_entries.maximum(:id) if latest_entry_id && total.last_entry_id != latest_entry_id scope = storage_entries.where(id: ..latest_entry_id) scope = scope.where.not(id: ..total.last_entry_id) if total.last_entry_id delta_sum = scope.sum(:delta) total.update! bytes_stored: total.bytes_stored + delta_sum, last_entry_id: latest_entry_id end end end # Reconcile ledger against actual attachment storage. # # Uses two-cursor approach for consistency: capture cursor before AND after the # scan. If they differ, entries were added during the scan and we can't get an # accurate diff without risking double-counting or undercounting. # # Returns true if reconciled successfully, false if aborted due to concurrent # writes. Caller (ReconcileJob) handles retries to avoid amplification. def reconcile_storage cursor_before = storage_entries.maximum(:id) real_bytes = calculate_real_storage_bytes cursor_after = storage_entries.maximum(:id) if cursor_before != cursor_after Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan" false else ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0 diff = real_bytes - ledger_bytes if diff.nonzero? Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes" Storage::Entry.record \ account: is_a?(Account) ? self : account, board: is_a?(Board) ? self : nil, recordable: nil, delta: diff, operation: "reconcile" end true end end private def create_or_find_storage_total self.storage_total ||= Storage::Total.create_or_find_by!(owner: self) end def calculate_real_storage_bytes raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes" end end ================================================ FILE: app/models/concerns/storage/tracked.rb ================================================ # Storage tracking is a business abstraction - we count what users upload. # Original upload bytes only; variants/previews/derivatives excluded. # Physical storage optimizations (deduplication, compression) don't affect quotas. module Storage::Tracked extend ActiveSupport::Concern included do before_update :track_board_transfer, if: :board_transfer? end # Return self as the trackable record for storage entries def storage_tracked_record self end # Override in models where board is determined differently (e.g., Board itself) def board_for_storage_tracking board end # Total bytes for all attachments on this record def storage_bytes attachments_for_storage.sum { |a| a.blob.byte_size } end private def board_transfer? respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id? end def track_board_transfer old_board = Board.find_by(id: attribute_in_database(:board_id)) records = storage_transfer_records.compact return if records.empty? attachments_by_record = storage_attachments_for_records(records) attachments_by_record.each do |recordable, attachments| bytes = attachments.sum { |attachment| attachment.blob.byte_size } next if bytes.zero? # Debit old board if old_board Storage::Entry.record \ account: account, board: old_board, recordable: recordable, delta: -bytes, operation: "transfer_out" end # Credit new board Storage::Entry.record \ account: account, board: board, recordable: recordable, delta: bytes, operation: "transfer_in" end end def storage_transfer_records [ self ] end # Override if needed. Default = all direct attachments def attachments_for_storage(recordable = self) storage_attachments_for_records([ recordable ]).fetch(recordable, []) end def storage_attachments_for_records(recordables) records = Array(recordables).compact return {} if records.empty? # Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents records_by_key = records.index_by { |r| [ r.class.name, r.id ] } rich_texts = ActionText::RichText.where(record: records) rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] } attachments = ActiveStorage::Attachment .where(record: records + rich_texts) .includes(:blob) .to_a attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped| # Resolve parent without N+1: use lookup for RichText, direct for others recordable = if attachment.record_type == "ActionText::RichText" rich_text_to_parent[attachment.record_id] else records_by_key[[ attachment.record_type, attachment.record_id ]] end grouped[recordable] << attachment if recordable end end end ================================================ FILE: app/models/current.rb ================================================ class Current < ActiveSupport::CurrentAttributes attribute :session, :user, :identity, :account attribute :http_method, :request_id, :user_agent, :ip_address, :referrer def session=(value) super(value) if value.present? self.identity = session.identity end end def identity=(identity) super(identity) if identity.present? self.user = identity.users.find_by(account: account) end end def with_account(value, &) with(account: value, &) end def without_account(&) with(account: nil, &) end end ================================================ FILE: app/models/entropy.rb ================================================ class Entropy < ApplicationRecord DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30 AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze belongs_to :account, default: -> { container.account } belongs_to :container, polymorphic: true validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS } after_commit -> { container.cards.touch_all if container } def auto_postpone_period_in_days days = auto_postpone_period / 1.day.to_i if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS) days else default_auto_postpone_period_in_days end end def auto_postpone_period_in_days=(new_value) self.auto_postpone_period = new_value.to_i.days.to_i end private def default_auto_postpone_period_in_days if container.is_a?(Board) container.account.entropy.auto_postpone_period_in_days else DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS end end end ================================================ FILE: app/models/event/description.rb ================================================ class Event::Description include ActionView::Helpers::TagHelper include ERB::Util attr_reader :event, :user def initialize(event, user) @event = event @user = user end def to_html to_sentence(creator_tag, card_title_tag).html_safe end def to_plain_text to_sentence(creator_name, quoted(card.title)).html_safe end private def to_sentence(creator, card_title) if event.action.comment_created? comment_sentence(creator, card_title) else action_sentence(creator, card_title) end end def creator_tag tag.span data: { creator_id: event.creator.id } do tag.span("You", data: { only_visible_to_you: true }) + tag.span(event.creator.name, data: { only_visible_to_others: true }) end end def card_title_tag tag.span card.title, class: "txt-underline" end def creator_name h event.creator.name end def quoted(text) h %("#{text}") end def card @card ||= event.action.comment_created? ? event.eventable.card : event.eventable end def comment_sentence(creator, card_title) "#{creator} commented on #{card_title}" end def action_sentence(creator, card_title) case event.action when "card_assigned" assigned_sentence(creator, card_title) when "card_unassigned" unassigned_sentence(creator, card_title) when "card_published" "#{creator} added #{card_title}" when "card_closed" %(#{creator} moved #{card_title} to "Done") when "card_reopened" "#{creator} reopened #{card_title}" when "card_postponed" %(#{creator} moved #{card_title} to "Not Now") when "card_auto_postponed" %(#{card_title} moved to "Not Now" due to inactivity) when "card_resumed" "#{creator} resumed #{card_title}" when "card_title_changed" renamed_sentence(creator, card_title) when "card_board_changed", "card_collection_changed" moved_sentence(creator, card_title) when "card_triaged" triaged_sentence(creator, card_title) when "card_sent_back_to_triage" %(#{creator} moved #{card_title} back to "Maybe?") end end def assigned_sentence(creator, card_title) if event.assignees.include?(user) "#{creator} will handle #{card_title}" else "#{creator} assigned #{assignee_names} to #{card_title}" end end def unassigned_sentence(creator, card_title) "#{creator} unassigned #{unassigned_names} from #{card_title}" end def renamed_sentence(creator, card_title) %(#{creator} renamed #{card_title} (was: "#{old_title}")) end def moved_sentence(creator, card_title) %(#{creator} moved #{card_title} to "#{new_location}") end def triaged_sentence(creator, card_title) %(#{creator} moved #{card_title} to "#{column}") end def assignee_names h event.assignees.pluck(:name).to_sentence end def unassigned_names h(event.assignees.include?(user) ? "yourself" : assignee_names) end def old_title h event.particulars.dig("particulars", "old_title") end def new_location h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")) end def column h event.particulars.dig("particulars", "column") end end ================================================ FILE: app/models/event/particulars.rb ================================================ module Event::Particulars extend ActiveSupport::Concern included do store_accessor :particulars, :assignee_ids end def assignees @assignees ||= User.where id: assignee_ids end end ================================================ FILE: app/models/event/promptable.rb ================================================ module Event::Promptable extend ActiveSupport::Concern def to_prompt <<~PROMPT BEGIN OF EVENT #{id} ## Event #{action} (#{eventable_type} #{eventable_id})) * Created at: #{created_at} * Created by: #{creator.name} #{eventable.to_prompt} END OF EVENT #{id} PROMPT end end ================================================ FILE: app/models/event.rb ================================================ class Event < ApplicationRecord include Notifiable, Particulars, Promptable belongs_to :account, default: -> { board.account } belongs_to :board belongs_to :creator, class_name: "User" belongs_to :eventable, polymorphic: true has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { includes(:creator, :board, { eventable: [ :goldness, :closure, :image_attachment, { rich_text_body: :embeds_attachments }, { rich_text_description: :embeds_attachments }, { card: [ :goldness, :closure, :image_attachment ] } ] }) } after_create -> { eventable.event_was_created(self) } after_create_commit :dispatch_webhooks delegate :card, to: :eventable def action super.inquiry end def notifiable_target eventable end def description_for(user) Event::Description.new(self, user) end private def dispatch_webhooks Event::WebhookDispatchJob.perform_later(self) end end ================================================ FILE: app/models/export.rb ================================================ class Export < ApplicationRecord belongs_to :account belongs_to :user has_one_attached :file enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending scope :current, -> { where(created_at: 24.hours.ago..) } scope :expired, -> { where(completed_at: ...24.hours.ago) } def self.cleanup expired.destroy_all end def build_later DataExportJob.perform_later(self) end def build processing! with_context do ZipFile.create_for(file, filename: filename) do |zip| populate_zip(zip) end mark_completed ExportMailer.completed(self).deliver_later end rescue => e update!(status: :failed) raise e end def mark_completed update!(status: :completed, completed_at: Time.current) end def accessible_to?(accessor) accessor == user end private def filename "fizzy-export-#{id}.zip" end def with_context Current.set(account: account) do old_url_options = ActiveStorage::Current.url_options ActiveStorage::Current.url_options = Rails.application.routes.default_url_options yield ensure ActiveStorage::Current.url_options = old_url_options end end def populate_zip(zip) raise NotImplementedError, "Subclasses must implement populate_zip" end end ================================================ FILE: app/models/filter/fields.rb ================================================ module Filter::Fields extend ActiveSupport::Concern INDEXES = %w[ all closed not_now stalled postponing_soon golden ] SORTED_BY = %w[ newest oldest latest ] delegate :default_value?, to: :class class_methods do def default_values { indexed_by: "all", sorted_by: "latest" } end def default_value?(key, value) default_values[key.to_sym].eql?(value) end def indexed_by_human_name(index) case index when "postponing_soon" "Closing soon" when "closed" "Done" when "all" "Open" else index.humanize end end end included do store_accessor :fields, :assignment_status, :indexed_by, :sorted_by, :terms, :card_ids, :creation, :closure def assignment_status super.to_s.inquiry end def indexed_by (super || default_indexed_by).inquiry end def sorted_by (super || default_sorted_by).inquiry end def creation_window TimeWindowParser.parse(creation) end def closure_window TimeWindowParser.parse(closure) end def terms Array(super) end def terms=(value) super(Array(value).filter(&:present?)) end end def with(**fields) creator.filters.from_params(as_params).tap do |filter| fields.each do |key, value| filter.public_send("#{key}=", value) end end end def default_indexed_by self.class.default_values[:indexed_by] end def default_indexed_by? default_value?(:indexed_by, indexed_by) end def default_sorted_by self.class.default_values[:sorted_by] end def default_sorted_by? default_value?(:sorted_by, sorted_by) end end ================================================ FILE: app/models/filter/params.rb ================================================ module Filter::Params extend ActiveSupport::Concern PERMITTED_PARAMS = [ :assignment_status, :indexed_by, :sorted_by, :creation, :closure, card_ids: [], assignee_ids: [], creator_ids: [], closer_ids: [], board_ids: [], tag_ids: [], terms: [] ] class_methods do def find_by_params(params) find_by params_digest: digest_params(params) end def digest_params(params) Digest::MD5.hexdigest normalize_params(params).to_json end def normalize_params(params) params .to_h .compact_blank .reject(&method(:default_value?)) .collect { |name, value| [ name, value.is_a?(Array) ? value.collect(&:to_s) : value.to_s ] } .sort_by { |name, _| name.to_s } .to_h end end included do before_save { self.params_digest = self.class.digest_params(as_params) } end def used?(ignore_boards: false) tags.any? || assignees.any? || creators.any? || closers.any? || terms.any? || card_ids&.any? || (!ignore_boards && boards.present?) || assignment_status.unassigned? || !indexed_by.all? || !sorted_by.latest? end # +as_params+ uses `resource#ids` instead of `#resource_ids` # because the latter won't work on unpersisted filters. def as_params @as_params ||= {}.tap do |params| params[:indexed_by] = indexed_by params[:sorted_by] = sorted_by params[:creation] = creation params[:closure] = closure params[:assignment_status] = assignment_status params[:terms] = terms params[:tag_ids] = tags.ids params[:board_ids] = boards.ids params[:card_ids] = card_ids params[:assignee_ids] = assignees.ids params[:creator_ids] = creators.ids params[:closer_ids] = closers.ids end.compact_blank.reject(&method(:default_value?)) end def as_params_without(key, value) as_params.dup.tap do |params| if params[key].is_a?(Array) params[key] = params[key] - [ value ] params.delete(key) if params[key].empty? elsif params[key] == value params.delete(key) end end end def params_digest super.presence || self.class.digest_params(as_params) end end ================================================ FILE: app/models/filter/resources.rb ================================================ module Filter::Resources extend ActiveSupport::Concern included do has_and_belongs_to_many :tags has_and_belongs_to_many :boards has_and_belongs_to_many :assignees, class_name: "User", join_table: "assignees_filters", association_foreign_key: "assignee_id" has_and_belongs_to_many :creators, class_name: "User", join_table: "creators_filters", association_foreign_key: "creator_id" has_and_belongs_to_many :closers, class_name: "User", join_table: "closers_filters", association_foreign_key: "closer_id" end def resource_removed(resource) kind = resource.class.model_name.plural send "#{kind}=", send(kind).without(resource) @boards = nil empty? ? destroy! : save! rescue ActiveRecord::RecordNotUnique destroy! end def boards @boards ||= creator.boards.where id: super.ids end def board_titles if boards.none? creator.boards.one? ? [ creator.boards.first.name ] : [ "all boards" ] else boards.map(&:name) end end def boards_label board_titles.to_sentence end end ================================================ FILE: app/models/filter/summarized.rb ================================================ module Filter::Summarized def summary [ index_summary, sort_summary, tag_summary, assignee_summary, creator_summary, terms_summary ].compact.to_sentence end private def index_summary unless indexed_by.all? indexed_by.humanize end end def sort_summary unless sorted_by.latest? sorted_by.humanize end end def tag_summary if tags.any? "#{tags.map(&:hashtag).to_choice_sentence}" end end def assignee_summary if assignees.any? "assigned to #{assignees.pluck(:name).to_choice_sentence}" elsif assignment_status.unassigned? "assigned to no one" end end def terms_summary if terms.any? "matching #{terms.map { |term| %Q("#{term}") }.to_sentence}" end end def creator_summary if creators.any? "added by #{creators.pluck(:name).to_choice_sentence}" end end end ================================================ FILE: app/models/filter.rb ================================================ class Filter < ApplicationRecord include Fields, Params, Resources, Summarized belongs_to :creator, class_name: "User", default: -> { Current.user } belongs_to :account, default: -> { creator.account } class << self def from_params(params) find_by_params(params) || build(params) end def remember(attrs) create!(attrs) rescue ActiveRecord::RecordNotUnique find_by_params(attrs).tap(&:touch) end end def cards @cards ||= begin result = creator.accessible_cards.preloaded.published result = result.indexed_by(indexed_by) result = result.sorted_by(sorted_by) result = result.where(id: card_ids) if card_ids.present? result = result.where.missing(:not_now) unless include_not_now_cards? result = result.open unless include_closed_cards? result = result.unassigned if assignment_status.unassigned? result = result.assigned_to(assignees.ids) if assignees.present? result = result.where(creator_id: creators.ids) if creators.present? result = result.where(board: boards.ids) if boards.present? result = result.tagged_with(tags.ids) if tags.present? result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| result.mentioning(term, user: creator) end result.distinct end end def empty? self.class.normalize_params(as_params).blank? end def single_board boards.first if boards.one? end def single_workflow boards.first.workflow if boards.pluck(:workflow_id).uniq.one? end def cacheable? boards.exists? end def cache_key ActiveSupport::Cache.expand_cache_key params_digest, "filter" end def only_closed? indexed_by.closed? || closure_window || closers.present? end private def include_closed_cards? only_closed? || card_ids.present? end def include_not_now_cards? indexed_by.not_now? || card_ids.present? end end ================================================ FILE: app/models/identity/access_token.rb ================================================ class Identity::AccessToken < ApplicationRecord belongs_to :identity has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read def allows?(method) method.in?(%w[ GET HEAD ]) || write? end end ================================================ FILE: app/models/identity/joinable.rb ================================================ module Identity::Joinable extend ActiveSupport::Concern def join(account, **attributes) attributes[:name] ||= email_address transaction do account.users.find_or_create_by!(identity: self) do |user| user.assign_attributes(attributes) end.previously_new_record? end end end ================================================ FILE: app/models/identity/transferable.rb ================================================ module Identity::Transferable extend ActiveSupport::Concern TRANSFER_LINK_EXPIRY_DURATION = 4.hours class_methods do def find_by_transfer_id(id) find_signed(id, purpose: :transfer) end end def transfer_id signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION) end end ================================================ FILE: app/models/identity.rb ================================================ class Identity < ApplicationRecord include Joinable, Transferable has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address } has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy has_many :users, dependent: :nullify has_many :accounts, through: :users has_one_attached :avatar before_destroy :deactivate_users, prepend: true validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } def self.find_by_permissable_access_token(token, method:) if (access_token = AccessToken.find_by(token: token)) && access_token.allows?(method) access_token.identity end end def send_magic_link(**attributes) attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for) magic_links.create!(attributes).tap do |magic_link| MagicLinkMailer.sign_in_instructions(magic_link).deliver_later end end def users_with_active_accounts users.joins(:account).merge(Account.active).includes(:account) end private def deactivate_users users.find_each(&:deactivate) end end ================================================ FILE: app/models/magic_link/code.rb ================================================ module MagicLink::Code CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze class << self def generate(length) SecureRandom.base32(length) end def sanitize(code) if code.present? normalize_code(code) .then { apply_substitutions(it) } .then { remove_invalid_characters(it) } end end private def normalize_code(code) code.to_s.upcase end def apply_substitutions(code) CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) } end def remove_invalid_characters(code) code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "") end end end ================================================ FILE: app/models/magic_link.rb ================================================ class MagicLink < ApplicationRecord CODE_LENGTH = 6 EXPIRATION_TIME = 15.minutes belongs_to :identity enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in scope :active, -> { where(expires_at: Time.current...) } scope :stale, -> { where(expires_at: ..Time.current) } before_validation :generate_code, on: :create before_validation :set_expiration, on: :create validates :code, uniqueness: true, presence: true class << self def consume(code) active.find_by(code: Code.sanitize(code))&.consume end def cleanup stale.delete_all end end def consume destroy self end private def generate_code self.code ||= loop do candidate = Code.generate(CODE_LENGTH) break candidate unless self.class.exists?(code: candidate) end end def set_expiration self.expires_at ||= EXPIRATION_TIME.from_now end end ================================================ FILE: app/models/mention.rb ================================================ class Mention < ApplicationRecord include Notifiable belongs_to :account, default: -> { source.account } belongs_to :source, polymorphic: true belongs_to :mentioner, class_name: "User" belongs_to :mentionee, class_name: "User", inverse_of: :mentions after_create_commit :watch_source_by_mentionee delegate :card, to: :source def self_mention? mentioner == mentionee end def notifiable_target source end private def watch_source_by_mentionee source.watch_by(mentionee) end end ================================================ FILE: app/models/notification/bundle.rb ================================================ class Notification::Bundle < ApplicationRecord belongs_to :account, default: -> { user.account } belongs_to :user enum :status, %i[ pending processing delivered ] scope :due, -> { pending.where("ends_at <= ?", Time.current) } scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) } scope :overlapping_with, ->(other_bundle) do where( "(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)", other_bundle.starts_at, other_bundle.starts_at, other_bundle.ends_at, other_bundle.ends_at, other_bundle.starts_at, other_bundle.ends_at ) end before_validation :set_default_window, if: :new_record? validate :validate_no_overlapping class << self def deliver_all due.in_batches do |batch| jobs = batch.collect { DeliverJob.new(it) } ActiveJob.perform_all_later jobs end end def deliver_all_later DeliverAllJob.perform_later end end def notifications user.notifications.where(updated_at: window).unread end def deliver user.in_time_zone do Current.with_account(user.account) do processing! Notification::BundleMailer.notification(self).deliver if deliverable? delivered! end end end def deliver_later DeliverJob.perform_later(self) end def flush update!(ends_at: Time.current) deliver_later end def set_default_window self.starts_at ||= Time.current self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period end private def window starts_at..ends_at end def validate_no_overlapping if overlapping_bundles.exists? errors.add(:base, "Bundle window overlaps with an existing pending bundle with id #{overlapping_bundles.first.id}") end end def deliverable? user.settings.bundling_emails? && notifications.any? && account.active? end def overlapping_bundles user.notification_bundles.where.not(id: id).overlapping_with(self) end end ================================================ FILE: app/models/notification/default_payload.rb ================================================ class Notification::DefaultPayload attr_reader :notification delegate :card, to: :notification def initialize(notification) @notification = notification end def to_h { title: title, body: body, url: url } end def title "New notification" end def body "You have a new notification" end def url notifications_url end def category "default" end def high_priority? false end def base_url Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/") end def avatar_url Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options) end private def card_url(card) Rails.application.routes.url_helpers.card_url(card, **url_options) end def notifications_url Rails.application.routes.url_helpers.notifications_url(**url_options) end def url_options base_options = Rails.application.routes.default_url_options.presence || Rails.application.config.action_mailer.default_url_options || {} base_options.merge(script_name: notification.account.slug) end end ================================================ FILE: app/models/notification/event_payload.rb ================================================ class Notification::EventPayload < Notification::DefaultPayload include ExcerptHelper def title case event.action when "comment_created" "RE: #{card_title}" else card_title end end def body case event.action when "comment_created" format_excerpt(event.eventable.body, length: 200) when "card_assigned" "Assigned to you by #{event.creator.name}" when "card_published" "Added by #{event.creator.name}" when "card_closed" card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}" when "card_reopened" "Reopened by #{event.creator.name}" when "card_triaged" if column_name.present? "Moved to #{column_name} by #{event.creator.name}" else "Moved by #{event.creator.name}" end when "card_sent_back_to_triage" "Moved back to Maybe? by #{event.creator.name}" when "card_board_changed", "card_collection_changed" if new_location_name.present? "Moved to #{new_location_name} by #{event.creator.name}" else "Moved by #{event.creator.name}" end when "card_title_changed" if new_title.present? "Renamed to #{new_title} by #{event.creator.name}" else "Renamed by #{event.creator.name}" end when "card_postponed" "Moved to Not Now by #{event.creator.name}" when "card_auto_postponed" "Moved to Not Now due to inactivity" else "Updated by #{event.creator.name}" end end def url case event.action when "comment_created" card_url_with_comment_anchor(event.eventable) else card_url(card) end end def category case event.action when "card_assigned" then "assignment" when "comment_created" then "comment" else "card" end end def high_priority? event.action.card_assigned? end private def event notification.source end def card_title card.title.presence || "Card #{card.number}" end def event_particulars event.particulars.dig("particulars") || {} end def column_name event_particulars["column"] end def new_location_name event_particulars["new_board"] || event_particulars["new_collection"] end def new_title event_particulars["new_title"] end def card_url_with_comment_anchor(comment) Rails.application.routes.url_helpers.card_url( comment.card, anchor: ActionView::RecordIdentifier.dom_id(comment), **url_options ) end end ================================================ FILE: app/models/notification/mention_payload.rb ================================================ class Notification::MentionPayload < Notification::DefaultPayload include ExcerptHelper def title "#{mention.mentioner.first_name} mentioned you" end def body format_excerpt(mention.source.mentionable_content, length: 200) end def url card_url(card) end def category "mention" end def high_priority? true end private def mention notification.source end end ================================================ FILE: app/models/notification/push_target/web.rb ================================================ class Notification::PushTarget::Web < Notification::PushTarget def process if subscriptions.any? Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions) end end private def subscriptions @subscriptions ||= notification.user.push_subscriptions end end ================================================ FILE: app/models/notification/push_target.rb ================================================ class Notification::PushTarget attr_reader :notification delegate :card, to: :notification def self.process(notification) new(notification).process end def initialize(notification) @notification = notification end def process raise NotImplementedError end end ================================================ FILE: app/models/notification/pushable.rb ================================================ module Notification::Pushable extend ActiveSupport::Concern included do class_attribute :push_targets, default: [] after_save_commit :push_later, if: :source_id_previously_changed? end class_methods do def register_push_target(target) target = resolve_push_target(target) push_targets << target unless push_targets.include?(target) end private def resolve_push_target(target) if target.is_a?(Symbol) "Notification::PushTarget::#{target.to_s.classify}".constantize else target end end end def push_later Notification::PushJob.perform_later(self) end def push return unless pushable? self.class.push_targets.each { |target| push_to(target) } end def payload "Notification::#{payload_type}Payload".constantize.new(self) end private def pushable? !creator.system? && user.active? && account.active? end def push_to(target) target.process(self) end def payload_type source_type.presence_in(%w[ Event Mention ]) || "Default" end end ================================================ FILE: app/models/notification.rb ================================================ class Notification < ApplicationRecord include Notification::Pushable belongs_to :account, default: -> { user.account } belongs_to :user belongs_to :creator, class_name: "User" belongs_to :source, polymorphic: true belongs_to :card scope :unread, -> { where(read_at: nil) } scope :read, -> { where.not(read_at: nil) } scope :ordered, -> { order(read_at: :desc, updated_at: :desc) } scope :preloaded, -> { preload( :creator, :account, card: [ :board, :column, :closure, :not_now ], source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ] ) } before_validation :set_card after_create :bundle after_update :bundle, if: :source_id_previously_changed? after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" } after_update_commit -> { broadcast_update } after_destroy_commit -> { broadcast_remove_to user, :notifications } delegate :notifiable_target, to: :source delegate :identity, to: :user class << self def read_all all.each(&:read) end def unread_all all.each(&:unread) end end def read update!(read_at: Time.current, unread_count: 0) end def unread update!(read_at: nil, unread_count: 1) end def read? read_at.present? end private def set_card self.card = source.card end def bundle user.bundle(self) if user.settings.bundling_emails? end def broadcast_update if read? broadcast_remove_to(user, :notifications) else broadcast_prepend_later_to(user, :notifications, target: "notifications") end end end ================================================ FILE: app/models/notifier/card_event_notifier.rb ================================================ class Notifier::CardEventNotifier < Notifier delegate :creator, to: :source delegate :board, to: :card private def recipients case source.action when "card_assigned" source.assignees.excluding(creator) when "card_published" board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq when "comment_created" card.watchers.without(creator, *source.eventable.scan_mentionees) else board.watchers.without(creator) end end def card source.eventable end end ================================================ FILE: app/models/notifier/comment_event_notifier.rb ================================================ class Notifier::CommentEventNotifier < Notifier delegate :creator, to: :source private def recipients card.watchers.without(creator, *source.eventable.scan_mentionees) end def card source.eventable.card end end ================================================ FILE: app/models/notifier/mention_notifier.rb ================================================ class Notifier::MentionNotifier < Notifier alias mention source private def recipients if mention.self_mention? [] else [ mention.mentionee ] end end def creator mention.mentioner end end ================================================ FILE: app/models/notifier.rb ================================================ class Notifier attr_reader :source class << self def for(source) case source when Event "Notifier::#{source.eventable.class}EventNotifier".safe_constantize&.new(source) when Mention MentionNotifier.new(source) end end end def notify if should_notify? # Processing recipients in order avoids deadlocks if notifications overlap. recipients.sort_by(&:id).map do |recipient| notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n| n.source = source n.creator = creator n.unread_count = 1 end unless notification.previously_new_record? # Always include source_type in the update to prevent a race condition between # concurrent Event and Mention notifier jobs: without this, Rails' dirty tracking # may skip source_type when it hasn't changed from the stale in-memory value, # even though another job has since modified it in the database, leaving # source_type and source_id mismatched. notification.source_type_will_change! notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1) end notification end end end private def initialize(source) @source = source end def should_notify? !creator.system? end end ================================================ FILE: app/models/passkey/authenticator.rb ================================================ class Passkey::Authenticator < Data.define(:aaguids, :name, :icon) class << self def find_by_aaguid(aaguid) registry[aaguid] end def registry @registry ||= Hash.new.tap do |registry| all.each do |authenticator| authenticator.aaguids.each do |aaguid| registry[aaguid] = authenticator end end end end def all Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs| new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon]) end end end end ================================================ FILE: app/models/pin.rb ================================================ class Pin < ApplicationRecord belongs_to :account, default: -> { user.account } belongs_to :card belongs_to :user scope :ordered, -> { order(created_at: :desc) } end ================================================ FILE: app/models/push/subscription.rb ================================================ class Push::Subscription < ApplicationRecord PERMITTED_ENDPOINT_HOSTS = %w[ jmt17.google.com fcm.googleapis.com updates.push.services.mozilla.com web.push.apple.com notify.windows.com ].freeze belongs_to :account, default: -> { user.account } belongs_to :user validates :endpoint, presence: true validate :validate_endpoint_url def notification(**params) WebPush::Notification.new( **params, badge: user.notifications.unread.count, endpoint: endpoint, endpoint_ip: resolved_endpoint_ip, p256dh_key: p256dh_key, auth_key: auth_key ) end def resolved_endpoint_ip return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip) @resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host) end private def endpoint_uri @endpoint_uri ||= URI.parse(endpoint) if endpoint.present? rescue URI::InvalidURIError nil end def validate_endpoint_url if endpoint_uri.nil? errors.add(:endpoint, "is not a valid URL") elsif endpoint_uri.scheme != "https" errors.add(:endpoint, "must use HTTPS") elsif !permitted_endpoint_host? errors.add(:endpoint, "is not a permitted push service") elsif resolved_endpoint_ip.nil? errors.add(:endpoint, "resolves to a private or invalid IP address") end end def permitted_endpoint_host? host = endpoint_uri&.host&.downcase PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) } end end ================================================ FILE: app/models/push.rb ================================================ module Push def self.table_name_prefix "push_" end end ================================================ FILE: app/models/qr_code_link.rb ================================================ class QrCodeLink attr_reader :url class << self def from_signed(signed) new verifier.verify(signed, purpose: :qr_code) end def verifier ActiveSupport::MessageVerifier.new(secret, url_safe: true) end private def secret Rails.application.key_generator.generate_key("qr_codes") end end def initialize(url) @url = url end def signed self.class.verifier.generate(@url, purpose: :qr_code) end end ================================================ FILE: app/models/reaction.rb ================================================ class Reaction < ApplicationRecord belongs_to :account, default: -> { reactable.account } belongs_to :reactable, polymorphic: true, touch: true belongs_to :reacter, class_name: "User", default: -> { Current.user } scope :ordered, -> { order(:created_at) } after_create :register_card_activity delegate :all_emoji?, to: :content private def register_card_activity reactable.card.touch_last_active_at end end ================================================ FILE: app/models/search/highlighter.rb ================================================ class Search::Highlighter OPENING_MARK = "" CLOSING_MARK = "" ELIPSIS = "..." attr_reader :query def initialize(query) @query = query end def highlight(text) result = text.dup terms.each do |term| result.gsub!(/\b(#{Regexp.escape(term)}\w*)\b/i) do |match| "#{OPENING_MARK}#{match}#{CLOSING_MARK}" end end escape_highlight_marks(result) end def snippet(text, max_words: 20) words = text.split(/\s+/) match_index = words.index { |word| terms.any? { |term| word.downcase.include?(term.downcase) } } if words.length <= max_words highlight(text) elsif match_index start_index = [ 0, match_index - max_words / 2 ].max end_index = [ words.length - 1, start_index + max_words - 1 ].min snippet_text = words[start_index..end_index].join(" ") snippet_text = "...#{snippet_text}" if start_index > 0 snippet_text = "#{snippet_text}..." if end_index < words.length - 1 highlight(snippet_text) else text.truncate_words(max_words, omission: "...") end end private def terms @terms ||= begin terms = [] query.scan(/"([^"]+)"/) do |phrase| terms << phrase.first end unquoted = query.gsub(/"[^"]+"/, "") unquoted.split(/\s+/).each do |word| terms << word if word.present? end terms.uniq end end def escape_highlight_marks(html) CGI.escapeHTML(html) .gsub(CGI.escapeHTML(OPENING_MARK), OPENING_MARK.html_safe) .gsub(CGI.escapeHTML(CLOSING_MARK), CLOSING_MARK.html_safe) .html_safe end end ================================================ FILE: app/models/search/query.rb ================================================ class Search::Query < ApplicationRecord belongs_to :account, default: -> { user&.account || Current.account } belongs_to :user, optional: true validates :terms, presence: true before_validation :sanitize_terms delegate :to_s, to: :terms class << self def wrap(query) if query.is_a?(self) query else self.new(terms: query) end end end private def sanitize_terms self.terms = sanitize(terms) end def sanitize(terms) if terms.present? terms = remove_invalid_search_characters(self.terms) terms = remove_unbalanced_quotes(terms) terms.presence else terms end end def remove_invalid_search_characters(terms) terms.gsub(/[^\w"]/, " ") end def remove_unbalanced_quotes(terms) if terms.count("\"").even? terms else terms.gsub("\"", " ") end end end ================================================ FILE: app/models/search/record/sqlite/fts.rb ================================================ class Search::Record::SQLite::Fts < ApplicationRecord self.table_name = "search_records_fts" self.primary_key = "rowid" # FTS5 virtual table columns attribute :rowid, :integer attribute :title, :string attribute :content, :string # FTS5 virtual tables don't expose rowid in the schema by default # We need to explicitly select it when loading records scope :with_rowid, -> { select(:rowid, :title, :content) } def self.upsert(rowid, title, content) connection.exec_query( "INSERT OR REPLACE INTO search_records_fts(rowid, title, content) VALUES (?, ?, ?)", "Search::Record::SQLite::Fts Upsert", [ rowid, title, content ] ) end end ================================================ FILE: app/models/search/record/sqlite.rb ================================================ module Search::Record::SQLite extend ActiveSupport::Concern included do attribute :result_title, :string attribute :result_content, :string has_one :search_records_fts, -> { with_rowid }, class_name: "Search::Record::SQLite::Fts", foreign_key: :rowid, primary_key: :id, dependent: :destroy after_save :upsert_to_fts5_table scope :matching, ->(query, account_id) { joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id") .where("search_records_fts MATCH ?", query) } end class_methods do def search_fields(query) opening_mark = connection.quote(Search::Highlighter::OPENING_MARK) closing_mark = connection.quote(Search::Highlighter::CLOSING_MARK) ellipsis = connection.quote(Search::Highlighter::ELIPSIS) [ "highlight(search_records_fts, 0, #{opening_mark}, #{closing_mark}) AS result_title", "snippet(search_records_fts, 1, #{opening_mark}, #{closing_mark}, #{ellipsis}, 20) AS result_content", "#{connection.quote(query.terms)} AS query" ] end def for(account_id) self end end def card_title escape_fts_highlight(result_title || card.title) end def card_description escape_fts_highlight(result_content) unless comment end def comment_body escape_fts_highlight(result_content) if comment end private def escape_fts_highlight(html) return nil unless html.present? CGI.escapeHTML(html) .gsub(CGI.escapeHTML(Search::Highlighter::OPENING_MARK), Search::Highlighter::OPENING_MARK) .gsub(CGI.escapeHTML(Search::Highlighter::CLOSING_MARK), Search::Highlighter::CLOSING_MARK) .html_safe end def upsert_to_fts5_table Fts.upsert(id, title, content) end end ================================================ FILE: app/models/search/record/trilogy.rb ================================================ module Search::Record::Trilogy extend ActiveSupport::Concern SHARD_COUNT = 16 included do self.abstract_class = true before_save :set_account_key, :stem_content scope :matching, ->(query, account_id) do full_query = "+account#{account_id} +(#{Search::Stemmer.stem(query)})" where("MATCH(#{table_name}.account_key, #{table_name}.content, #{table_name}.title) AGAINST(? IN BOOLEAN MODE)", full_query) end SHARD_CLASSES = SHARD_COUNT.times.map do |shard_id| Class.new(self) do self.table_name = "search_records_#{shard_id}" def self.name "Search::Record" end end end.freeze end class_methods do def shard_id_for_account(account_id) Zlib.crc32(account_id.to_s) % SHARD_COUNT end def search_fields(query) "#{connection.quote(query.terms)} AS query" end def for(account_id) SHARD_CLASSES[shard_id_for_account(account_id)] end end def card_title highlight(card.title, show: :full) if card_id end def card_description highlight(card.description.to_plain_text, show: :snippet) if card_id end def comment_body highlight(comment.body.to_plain_text, show: :snippet) if comment end private def stem_content self.title = Search::Stemmer.stem(title) if title_changed? self.content = Search::Stemmer.stem(content) if content_changed? end def set_account_key self.account_key = "account#{account_id}" end def highlight(text, show:) if text.present? && attribute?(:query) highlighter = Search::Highlighter.new(query) show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) else text end end end ================================================ FILE: app/models/search/record.rb ================================================ class Search::Record < ApplicationRecord include const_get(connection.adapter_name) belongs_to :searchable, polymorphic: true belongs_to :card validates :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :created_at, presence: true class << self def upsert!(attributes) record = find_by(searchable_type: attributes[:searchable_type], searchable_id: attributes[:searchable_id]) if record record.update!(attributes) record else create!(attributes) end end def card_join "INNER JOIN #{table_name} ON #{table_name}.card_id = cards.id" end end scope :for_query, ->(query, user:) do query = Search::Query.wrap(query) if query.valid? && user.board_ids.any? matching(query.to_s, user.account_id).where(account_id: user.account_id, board_id: user.board_ids) else none end end scope :search, ->(query, user:) do query = Search::Query.wrap(query) for_query(query, user: user) .includes(:searchable, card: [ :board, :creator ]) .order(created_at: :desc) .select(:id, :account_id, :searchable_type, :searchable_id, :card_id, :board_id, :title, :content, :created_at, *search_fields(query)) end def source searchable_type == "Comment" ? searchable : card end def comment searchable if searchable_type == "Comment" end end ================================================ FILE: app/models/search/result.rb ================================================ class Search::Result < ApplicationRecord attribute :card_id, :uuid attribute :comment_id, :uuid attribute :creator_id, :uuid belongs_to :creator, class_name: "User" belongs_to :card, foreign_key: :card_id, optional: true belongs_to :comment, foreign_key: :comment_id, optional: true def card_title highlight(card.title, show: :full) if card_id end def card_description highlight(card.description.to_plain_text, show: :snippet) if card_id end def comment_body highlight(comment.body.to_plain_text, show: :snippet) if comment_id end def source comment_id.present? ? comment : card end def readonly? true end private def highlight(text, show:) if text.present? && attribute?(:query) highlighter = Search::Highlighter.new(query) show == :snippet ? highlighter.snippet(text) : highlighter.highlight(text) else text end end end ================================================ FILE: app/models/search/stemmer.rb ================================================ module Search::Stemmer extend self STEMMER = Mittens::Stemmer.new def stem(value) if value.present? value.gsub(/[^\w\s]/, " ").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ") else value end end end ================================================ FILE: app/models/search.rb ================================================ module Search def self.table_name_prefix "search_" end end ================================================ FILE: app/models/session.rb ================================================ class Session < ApplicationRecord belongs_to :identity end ================================================ FILE: app/models/signup/account_name_generator.rb ================================================ class Signup::AccountNameGenerator SUFFIX = "Fizzy".freeze attr_reader :identity, :name def initialize(identity:, name:) @identity = identity @name = name end def generate next_index = current_index + 1 if next_index == 1 "#{prefix} #{SUFFIX}" else "#{prefix} #{next_index.ordinalize} #{SUFFIX}" end end private def current_index existing_indices.max || 0 end def existing_indices Current.without_account do identity.accounts.filter_map do |account| if account.name.match?(first_account_name_regex) 1 elsif match = account.name.match(nth_account_name_regex) match[1].to_i end end end end def first_account_name_regex @first_account_name_regex ||= /\A#{prefix}\s+#{SUFFIX}\Z/i end def nth_account_name_regex @nth_account_name_regex ||= /\A#{prefix}\s+(1st|2nd|3rd|\d+th)\s+#{SUFFIX}/i end def prefix @prefix ||= "#{first_name}'s" end def first_name name.strip.split(" ", 2).first end end ================================================ FILE: app/models/signup.rb ================================================ class Signup include ActiveModel::Model include ActiveModel::Attributes include ActiveModel::Validations attr_accessor :full_name, :email_address, :identity, :skip_account_seeding attr_reader :account, :user validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation validates :full_name, :identity, presence: true, on: :completion validates :full_name, length: { maximum: 240 } def initialize(...) super @email_address = @identity.email_address if @identity end def create_identity @identity = Identity.find_or_create_by!(email_address: email_address) @identity.send_magic_link for: :sign_up end def complete if valid?(:completion) begin @tenant = create_tenant create_account true rescue => error destroy_account handle_account_creation_error(error) errors.add(:base, "Something went wrong, and we couldn't create your account. Please give it another try.") Rails.error.report(error, severity: :error) Rails.logger.error error Rails.logger.error error.backtrace.join("\n") false end else false end end private # Override to customize the handling of external accounts associated to the account. def create_tenant nil end # Override to inject custom handling for account creation errors def handle_account_creation_error(error) end def create_account @account = Account.create_with_owner( account: { external_account_id: @tenant, name: generate_account_name }, owner: { name: full_name, identity: identity } ) @user = @account.users.find_by!(role: :owner) @account.setup_customer_template unless skip_account_seeding end def generate_account_name AccountNameGenerator.new(identity: identity, name: full_name).generate end def destroy_account @account&.destroy! @user = nil @account = nil @tenant = nil end def subscription_attributes subscription = FreeV1Subscription {}.tap do |attributes| attributes[:name] = subscription.to_param attributes[:price] = subscription.price end end def request_attributes {}.tap do |attributes| attributes[:remote_address] = Current.ip_address attributes[:user_agent] = Current.user_agent attributes[:referrer] = Current.referrer end end end ================================================ FILE: app/models/ssrf_protection.rb ================================================ module SsrfProtection extend self DNS_RESOLUTION_TIMEOUT = 2 DNS_NAMESERVERS = %w[ 1.1.1.1 8.8.8.8 ] DISALLOWED_IP_RANGES = [ IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700) IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598) IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544) ].freeze def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) public_ips = ip_addresses.reject { |ip| blocked_address?(ip) } public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end def blocked_address?(ip) ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr) ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || ip.ipv4_compat? || in_disallowed_range?(ip) end private def resolve_dns(hostname) ip_addresses = [] Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| dns.each_address(hostname) do |ip_address| ip_addresses << IPAddr.new(ip_address.to_s) end end ip_addresses end def in_disallowed_range?(ip) DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } end end ================================================ FILE: app/models/step.rb ================================================ class Step < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true scope :completed, -> { where(completed: true) } validates :content, presence: true def completed? completed end end ================================================ FILE: app/models/storage/attachment_tracking.rb ================================================ module Storage::AttachmentTracking extend ActiveSupport::Concern included do # Snapshot IDs in before_destroy since parent record may be deleted # by the time after_destroy_commit runs before_destroy :snapshot_storage_context after_create_commit :record_storage_attach after_destroy_commit :record_storage_detach end private def record_storage_attach return unless storage_tracked_record Storage::Entry.record \ account: storage_tracked_record.account, board: storage_tracked_record.board_for_storage_tracking, recordable: storage_tracked_record, blob: blob, delta: blob.byte_size, operation: "attach" end def record_storage_detach return unless @storage_snapshot Storage::Entry.record \ account: @storage_snapshot[:account], board: @storage_snapshot[:board], recordable: @storage_snapshot[:recordable], blob: blob, delta: -blob.byte_size, operation: "detach" end # Snapshot records in before_destroy since parent may be deleted by the time # after_destroy_commit runs. The records may be destroyed but .id still works. def snapshot_storage_context return unless storage_tracked_record @storage_snapshot = { account: storage_tracked_record.account, board: storage_tracked_record.board_for_storage_tracking, recordable: storage_tracked_record } end def storage_tracked_record record.try(:storage_tracked_record) end end ================================================ FILE: app/models/storage/entry.rb ================================================ class Storage::Entry < ApplicationRecord belongs_to :account belongs_to :board, optional: true belongs_to :recordable, polymorphic: true, optional: true scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id } # Records may be destroyed (during cascading deletes) but .id still works. # Skip entirely if account is destroyed - no need to track storage for deleted accounts. # Skip materialize jobs for destroyed boards since there's nothing to update. def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil) return if delta.zero? return if account.destroyed? entry = create! \ account_id: account.id, board_id: board&.id, recordable_type: recordable&.class&.name, recordable_id: recordable&.id, blob_id: blob&.id, delta: delta, operation: operation, user_id: Current.user&.id, request_id: Current.request_id account.materialize_storage_later board&.materialize_storage_later unless board&.destroyed? entry end end ================================================ FILE: app/models/storage/total.rb ================================================ class Storage::Total < ApplicationRecord belongs_to :owner, polymorphic: true def pending_entries owner.storage_entries.pending(last_entry_id) end # Exact current usage (snapshot + pending) def current_usage bytes_stored + pending_entries.sum(:delta) end end ================================================ FILE: app/models/storage.rb ================================================ module Storage def self.table_name_prefix "storage_" end # Record types that participate in storage tracking (ledger entries created on attach). # The no-reuse validation uses this to scope its check. # # IMPORTANT: Update this constant when adding tracked attachments to new models. # If you add a direct attachment (not via RichText embeds) to Comment, Board, or # another model with Storage::Tracked, you must add its record_type here or the # no-reuse validation won't protect it. TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze # Account ID for template/demo blobs that can be reused cross-tenant. # Set to nil to disable the whitelist (no exemptions). TEMPLATE_ACCOUNT_ID = nil end ================================================ FILE: app/models/tag/attachable.rb ================================================ module Tag::Attachable extend ActiveSupport::Concern included do include ActionText::Attachable def attachable_plain_text_representation(...) "##{title}" end end end ================================================ FILE: app/models/tag.rb ================================================ class Tag < ApplicationRecord include Attachable, Filterable belongs_to :account, default: -> { Current.account } has_many :taggings, dependent: :destroy has_many :cards, through: :taggings validates :title, format: { without: /\A#/ } normalizes :title, with: -> { it.downcase } scope :alphabetically, -> { order("lower(title)") } scope :unused, -> { left_outer_joins(:taggings).where(taggings: { id: nil }) } def hashtag "#" + title end def cards_count cards.open.count end end ================================================ FILE: app/models/tagging.rb ================================================ class Tagging < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :tag belongs_to :card, touch: true end ================================================ FILE: app/models/time_window_parser.rb ================================================ class TimeWindowParser attr_reader :now HUMAN_NAMES_BY_VALUE = { "today" => "Today", "yesterday" => "Yesterday", "thisweek" => "This week", "thismonth" => "This month", "thisyear" => "This year", "lastweek" => "Last week", "lastmonth" => "Last month", "lastyear" => "Last year" } VALUES = HUMAN_NAMES_BY_VALUE.keys class << self def parse(string) new.parse(string) end def human_name_for(value) HUMAN_NAMES_BY_VALUE[value] end end def initialize(now: Time.current) @now = now end def parse(string) case normalize(string) when "today" now.all_day when "yesterday" (now - 1.day).all_day when "thisweek" now.all_week when "thismonth" now.all_month when "thisyear" now.all_year when "lastweek" (now - 1.week).all_week when "lastmonth" (now - 1.month).all_month when "lastyear" (now - 1.year).all_year end end private def normalize(string) if string string.downcase.gsub(/[\s_\-]/, "") end end end ================================================ FILE: app/models/user/accessor.rb ================================================ module User::Accessor extend ActiveSupport::Concern included do has_many :accesses, dependent: :destroy has_many :boards, through: :accesses has_many :accessible_columns, through: :boards, source: :columns has_many :accessible_cards, through: :boards, source: :cards has_many :accessible_comments, through: :accessible_cards, source: :comments after_create_commit :grant_access_to_boards, unless: :system? end def draft_new_card_in(board) board.cards.find_or_initialize_by(creator: self, status: "drafted").tap do |card| card.update!(created_at: Time.current, updated_at: Time.current, last_active_at: Time.current) end end private def grant_access_to_boards Access.insert_all account.boards.all_access.ids.collect { |board_id| { id: ActiveRecord::Type::Uuid.generate, board_id: board_id, user_id: id, account_id: account.id } } end end ================================================ FILE: app/models/user/assignee.rb ================================================ module User::Assignee extend ActiveSupport::Concern included do has_many :assignments, foreign_key: :assignee_id, dependent: :destroy has_many :assignings, foreign_key: :assigner_id, class_name: "Assignment" has_many :assigned_cards, through: :assignments, source: :card end end ================================================ FILE: app/models/user/attachable.rb ================================================ module User::Attachable extend ActiveSupport::Concern included do include ActionText::Attachable def attachable_plain_text_representation(...) "@#{first_name.downcase}" end end end ================================================ FILE: app/models/user/avatar.rb ================================================ require "zlib" module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze AVATAR_COLORS = %w[ #AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53 #736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E ].freeze included do has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end scope :with_avatars, -> { preload(:account, :avatar_attachment) } validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached? end def avatar_attached? avatar.attached? end def avatar_thumbnail avatar.variable? ? avatar.variant(:thumb) : avatar end def avatar_background_color AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size] end # Avatars are always publicly accessible def publicly_accessible? true end private def avatar_content_type_allowed if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image") end end def avatar_dimensions_allowed return unless avatar.blob.analyzed? || avatar.blob.analyze width = avatar.blob.metadata[:width] height = avatar.blob.metadata[:height] if width && width > MAX_AVATAR_DIMENSIONS[:width] errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px") end if height && height > MAX_AVATAR_DIMENSIONS[:height] errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px") end end end ================================================ FILE: app/models/user/configurable.rb ================================================ module User::Configurable extend ActiveSupport::Concern included do has_one :settings, class_name: "User::Settings", dependent: :destroy has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all after_create :create_settings, unless: :system? delegate :timezone, to: :settings, allow_nil: true end def in_time_zone(&block) Time.use_zone(timezone, &block) end end ================================================ FILE: app/models/user/data_export.rb ================================================ class User::DataExport < Export private def filename "fizzy-user-data-export-#{id}.zip" end def populate_zip(zip) exportable_cards.find_each do |card| add_card_to_zip(zip, card) end end def exportable_cards user.accessible_cards.includes( :board, creator: :identity, comments: { creator: :identity }, rich_text_description: { embeds_attachments: :blob } ) end def add_card_to_zip(zip, card) zip.add_file("#{card.number}.json", card.export_json) card.export_attachments.each do |attachment| zip.add_file(attachment[:path], compress: false) do |f| attachment[:blob].download { |chunk| f.write(chunk) } end rescue ActiveStorage::FileNotFoundError # Skip attachments where the file is missing from storage end end end ================================================ FILE: app/models/user/day_timeline/column.rb ================================================ class User::DayTimeline::Column include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper attr_reader :index, :id, :base_title, :day_timeline, :events def initialize(day_timeline, id, base_title, index, events) @id = id @day_timeline = day_timeline @base_title = base_title @index = index @events = events end def title date_tag = local_datetime_tag(day_timeline.day, style: :agoorweekday) parts = [ base_title, date_tag ] parts << tag.span("(#{full_events_count})", class: "font-weight-normal") if full_events_count > 0 safe_join(parts, " ") end def events_by_hour limited_events.group_by { it.created_at.hour } end def has_more_events? limited_events.count < full_events_count end def hidden_events_count full_events_count - limited_events.count end def to_param id end private def limited_events @limited_events ||= events.limit(100).load end def full_events_count @full_events_count ||= events.count end end ================================================ FILE: app/models/user/day_timeline/serializable.rb ================================================ module User::DayTimeline::Serializable extend ActiveSupport::Concern included do include GlobalID::Identification # For active job serialization alias id to_json end class_methods do def find(id) data = JSON.parse(id).with_indifferent_access user = User.find(data[:user_id]) day = Time.zone.parse(data[:day]) filter = user.filters.from_params data[:filter_params] new(user, day, filter) end def tenanted? # TODO: Check with Mike false end end def as_json(options = {}) { user_id: user.id, day: day.to_s, filter_params: filter.as_params } end end ================================================ FILE: app/models/user/day_timeline.rb ================================================ class User::DayTimeline include Serializable attr_reader :user, :day, :filter delegate :today?, to: :day def initialize(user, day, filter) @user, @day, @filter = user, day, filter end def has_activity? events.any? end def events filtered_events.where(created_at: window).order(created_at: :desc) end def next_day latest_event_before&.created_at end def earliest_time next_day&.tomorrow&.beginning_of_day end def latest_time day.yesterday.beginning_of_day end def added_column @added_column ||= build_column(:added, "Added", 1, events.where(action: %w[card_published card_reopened])) end def updated_column @updated_column ||= build_column(:updated, "Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened])) end def closed_column @closed_column ||= build_column(:closed, "Done", 3, events.where(action: "card_closed")) end def cache_key ActiveSupport::Cache.expand_cache_key [ user, filter, day.to_date, events ], "day-timeline" end private TIMELINEABLE_ACTIONS = %w[ card_assigned card_unassigned card_published card_closed card_reopened card_collection_changed card_board_changed card_postponed card_auto_postponed card_triaged card_sent_back_to_triage comment_created ] def filtered_events @filtered_events ||= begin events = timelineable_events events = events.where(creator_id: filter.creators.ids) if filter.creators.present? events end end def timelineable_events Event .preloaded .where(board: boards) .where(action: TIMELINEABLE_ACTIONS) end def boards filter.boards.presence || user.boards end def latest_event_before filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last end def build_column(id, base_title, index, events) Column.new(self, id, base_title, index, events) end def window day.all_day end end ================================================ FILE: app/models/user/email_address_changeable.rb ================================================ module User::EmailAddressChangeable EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes extend ActiveSupport::Concern def change_email_address_using_token(token) parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE) old_email_address = parsed_token&.params&.fetch("old_email_address") new_email_address = parsed_token&.params&.fetch("new_email_address") if parsed_token.nil? || parsed_token.find != self || identity.email_address != old_email_address false else change_email_address(new_email_address) end end def send_email_address_change_confirmation(new_email_address) token = generate_email_address_change_token( to: new_email_address, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION ) UserMailer.email_change_confirmation( email_address: new_email_address, token: token, user: self ).deliver_later end def change_email_address(new_email_address) transaction do new_identity = Identity.find_or_create_by!(email_address: new_email_address) update!(identity: new_identity) end end private def generate_email_address_change_token(from: identity.email_address, to:, **options) options = options.with_defaults( for: EMAIL_CHANGE_TOKEN_PURPOSE, old_email_address: from, new_email_address: to, ) to_sgid(**options).to_s end end ================================================ FILE: app/models/user/filtering.rb ================================================ class User::Filtering attr_reader :user, :filter, :expanded delegate :as_params, :single_board, to: :filter delegate :only_closed?, to: :filter def initialize(user, filter, expanded: false) @user, @filter, @expanded = user, filter, expanded end def boards @boards ||= user.boards.ordered_by_recently_accessed end def selected_board_titles filter.board_titles end def selected_boards_label filter.boards_label end def tags @tags ||= account.tags.all.alphabetically end def users @users ||= account.users.active.alphabetically end def filters @filters ||= user.filters.all end def expanded? @expanded end def any? filter.used?(ignore_boards: true) end def show_indexed_by? !filter.indexed_by.all? end def show_sorted_by? !filter.sorted_by.latest? end def show_tags? return unless Tag.any? filter.tags.any? end def show_assignees? filter.assignees.any? end def show_creators? filter.creators.any? end def show_closers? filter.closers.any? end def show_boards? filter.boards.any? end def single_board_or_first # Default to the first selected or, when no selection, to the first one filter.boards.first || boards.first end def cache_key ActiveSupport::Cache.expand_cache_key([ user, filter, expanded?, boards, tags, users, filters ], "user-filtering") end private def account user.account end end ================================================ FILE: app/models/user/mentionable.rb ================================================ module User::Mentionable extend ActiveSupport::Concern included do has_many :mentions, dependent: :destroy, inverse_of: :mentionee # Need to set in the included block so that it overrides Action Text's def to_attachable_partial_path "users/attachable" end end def mentioned_by(mentioner, at:) mentions.find_or_create_by! source: at, mentioner: mentioner end def mentionable_handles [ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase) end def content_type "application/vnd.actiontext.mention" end private def first_name_with_last_name_initial "#{first_name}#{last_name&.first}" end end ================================================ FILE: app/models/user/named.rb ================================================ module User::Named extend ActiveSupport::Concern included do scope :alphabetically, -> { order("lower(name)") } end def first_name name.split(/\s/).first end def last_name name.split(/\s/, 2).last end def initials name.scan(/\b\p{L}/).join.upcase end def familiar_name names = name.split return name if names.length <= 1 "#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}" end end ================================================ FILE: app/models/user/notifiable.rb ================================================ module User::Notifiable extend ActiveSupport::Concern included do has_many :notifications, dependent: :destroy has_many :notification_bundles, class_name: "Notification::Bundle", dependent: :destroy generates_token_for :unsubscribe, expires_in: 1.month end def bundle(notification) with_lock do find_or_create_bundle_for(notification) end end private def find_or_create_bundle_for(notification) find_bundle_for(notification) || expand_pending_bundle_for(notification) || create_bundle_for(notification) end def find_bundle_for(notification) notification_bundles.pending.containing(notification).last end def expand_pending_bundle_for(notification) pending = notification_bundles.pending.last if pending.present? && notification.updated_at < pending.starts_at pending.update!(starts_at: notification.updated_at) # expand the window to include this notification end end def create_bundle_for(notification) notification_bundles.create!(starts_at: notification.updated_at) end end ================================================ FILE: app/models/user/role.rb ================================================ module User::Role extend ActiveSupport::Concern included do enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false scope :owner, -> { where(active: true, role: :owner) } scope :admin, -> { where(active: true, role: %i[ owner admin ]) } scope :member, -> { where(active: true, role: :member) } scope :active, -> { where(active: true, role: %i[ owner admin member ]) } def admin? super || owner? end end def can_change?(other) (admin? && !other.owner?) || other == self end def can_administer?(other) admin? && !other.owner? && other != self end def can_administer_board?(board) admin? || board.creator == self end def can_administer_card?(card) admin? || card.creator == self end end ================================================ FILE: app/models/user/searcher.rb ================================================ module User::Searcher extend ActiveSupport::Concern included do has_many :search_queries, class_name: "Search::Query", dependent: :destroy end def search(terms) Search::Record.for(account_id).search(terms, user: self) end def remember_search(terms) search_queries.find_or_create_by(terms: terms).tap do |search_query| search_query.touch unless search_query.invalid? || search_query.previously_new_record? end end end ================================================ FILE: app/models/user/settings.rb ================================================ class User::Settings < ApplicationRecord belongs_to :account, default: -> { user.account } belongs_to :user enum :bundle_email_frequency, %i[ never every_few_hours daily weekly ], default: :every_few_hours, prefix: :bundle_email after_update :review_pending_bundles, if: :saved_change_to_bundle_email_frequency? def bundle_aggregation_period case bundle_email_frequency when "every_few_hours" 4.hours when "daily" 1.day when "weekly" 1.week else 1.day end end def bundling_emails? !bundle_email_never? && !user.system? && user.active? && user.verified? end def timezone if timezone_name.present? ActiveSupport::TimeZone[timezone_name] || default_timezone else default_timezone end end private def review_pending_bundles if bundling_emails? flush_pending_bundles else cancel_pending_bundles end end def cancel_pending_bundles user.notification_bundles.pending.find_each do |bundle| bundle.destroy end end def flush_pending_bundles user.notification_bundles.pending.find_each(&:flush) end def default_timezone ActiveSupport::TimeZone["UTC"] end end ================================================ FILE: app/models/user/timelined.rb ================================================ module User::Timelined extend ActiveSupport::Concern included do has_many :accessible_events, through: :boards, source: :events end def timeline_for(day, filter:) User::DayTimeline.new(self, day, filter) end end ================================================ FILE: app/models/user/transferable.rb ================================================ module User::Transferable extend ActiveSupport::Concern TRANSFER_LINK_EXPIRY_DURATION = 4.hours class_methods do def find_by_transfer_id(id) find_signed(id, purpose: :transfer) end end def transfer_id signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION) end end ================================================ FILE: app/models/user/watcher.rb ================================================ module User::Watcher extend ActiveSupport::Concern included do has_many :watches, dependent: :destroy end end ================================================ FILE: app/models/user.rb ================================================ class User < ApplicationRecord include Accessor, Assignee, Attachable, Avatar, Configurable, EmailAddressChangeable, Mentionable, Named, Notifiable, Role, Searcher, Watcher include Timelined # Depends on Accessor belongs_to :account belongs_to :identity, optional: true validates :name, presence: true has_many :comments, inverse_of: :creator, dependent: :destroy has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card has_many :data_exports, class_name: "User::DataExport", dependent: :destroy def deactivate transaction do accesses.destroy_all update! active: false, identity: nil close_remote_connections end end def setup? name != identity.email_address end def verified? verified_at.present? end def verify update!(verified_at: Time.current) unless verified? end private def close_remote_connections ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false) end end ================================================ FILE: app/models/watch.rb ================================================ class Watch < ApplicationRecord belongs_to :account, default: -> { user.account } belongs_to :user belongs_to :card, touch: true scope :watching, -> { where(watching: true) } scope :not_watching, -> { where(watching: false) } end ================================================ FILE: app/models/webhook/delinquency_tracker.rb ================================================ class Webhook::DelinquencyTracker < ApplicationRecord DELINQUENCY_THRESHOLD = 10 DELINQUENCY_DURATION = 1.hour belongs_to :account, default: -> { webhook.account } belongs_to :webhook def record_delivery_of(delivery) if delivery.succeeded? reset else mark_first_failure_time if consecutive_failures_count.zero? increment!(:consecutive_failures_count, touch: true) webhook.deactivate if delinquent? end end private def reset update_columns consecutive_failures_count: 0, first_failure_at: nil end def mark_first_failure_time update_columns first_failure_at: Time.current end def delinquent? failing_for_too_long? && too_many_consecutive_failures? end def failing_for_too_long? if first_failure_at first_failure_at.before?(DELINQUENCY_DURATION.ago) else false end end def too_many_consecutive_failures? consecutive_failures_count >= DELINQUENCY_THRESHOLD end end ================================================ FILE: app/models/webhook/delivery.rb ================================================ class Webhook::Delivery < ApplicationRecord include Rails.application.routes.url_helpers class ResponseTooLarge < StandardError; end STALE_TRESHOLD = 7.days USER_AGENT = "fizzy/1.0.0 Webhook" ENDPOINT_TIMEOUT = 7.seconds MAX_RESPONSE_SIZE = 100.kilobytes belongs_to :account, default: -> { webhook.account } belongs_to :webhook belongs_to :event store :request, coder: JSON store :response, coder: JSON enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending scope :ordered, -> { order created_at: :desc, id: :desc } scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) } after_create_commit :deliver_later def self.cleanup(batch_size: 500, pause: 0.1) sleep pause until stale.limit(batch_size).delete_all.zero? end def deliver_later Webhook::DeliveryJob.perform_later(self) end def deliver in_progress! self.request[:headers] = headers self.response = perform_request self.state = :completed save! webhook.delinquency_tracker.record_delivery_of(self) rescue errored! raise end def failed? (errored? || completed?) && !succeeded? end def succeeded? completed? && response[:error].blank? && response[:code].between?(200, 299) end private def perform_request if resolved_ip.nil? { error: :private_uri } else request = Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload } response = http.request(request) do |net_http_response| stream_body_with_limit(net_http_response) end { code: response.code.to_i } end rescue ResponseTooLarge { error: :response_too_large } rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError { error: :dns_lookup_failed } rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT { error: :connection_timeout } rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET { error: :destination_unreachable } rescue OpenSSL::SSL::SSLError { error: :failed_tls } end def stream_body_with_limit(response) bytes_read = 0 response.read_body do |chunk| bytes_read += chunk.bytesize raise ResponseTooLarge if bytes_read > MAX_RESPONSE_SIZE end end def resolved_ip return @resolved_ip if defined?(@resolved_ip) @resolved_ip = SsrfProtection.resolve_public_ip(uri.host) end def uri @uri ||= URI(webhook.url) end def http Net::HTTP.new(uri.host, uri.port).tap do |http| http.ipaddr = resolved_ip http.use_ssl = (uri.scheme == "https") http.open_timeout = ENDPOINT_TIMEOUT http.read_timeout = ENDPOINT_TIMEOUT end end def headers { "User-Agent" => USER_AGENT, "Content-Type" => content_type, "X-Webhook-Signature" => signature, "X-Webhook-Timestamp" => event.created_at.utc.iso8601 } end def signature OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload) end def content_type if webhook.for_campfire? "text/html" elsif webhook.for_basecamp? "application/x-www-form-urlencoded" else "application/json" end end def payload @payload ||= if webhook.for_basecamp? { content: render_payload(formats: :html) }.to_query elsif webhook.for_campfire? render_payload(formats: :html) elsif webhook.for_slack? slack_payload else render_payload(formats: :json) end end def render_payload(**options) webhook.renderer.render(layout: false, template: "webhooks/event", assigns: { event: event }, **options).strip end def convert_html_to_mrkdwn(html) document = Nokogiri::HTML5(html) document.css("a").each do |a| a.replace("<#{a["href"].strip}|#{a.text}>") if a["href"].present? end document.css("b").each do |b| b.replace("*#{b.text}*") end document.css("i").each do |i| i.replace("_#{i.text}_") end document.text end def slack_payload text = event.description_for(nil).to_plain_text url = polymorphic_url(event.eventable, base_url_options.merge(script_name: account.slug)) { text: "#{text} <#{url}|Open in Fizzy>" }.to_json end def base_url_options Rails.application.routes.default_url_options.presence || Rails.application.config.action_mailer.default_url_options end end ================================================ FILE: app/models/webhook/triggerable.rb ================================================ module Webhook::Triggerable extend ActiveSupport::Concern included do scope :triggered_by, ->(event) { where(board: event.board).triggered_by_action(event.action) } scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") } end def trigger(event) deliveries.create!(event: event) unless account.cancelled? end end ================================================ FILE: app/models/webhook.rb ================================================ class Webhook < ApplicationRecord include Triggerable SLACK_WEBHOOK_URL_REGEX = %r{//hooks\.slack\.com/services/T[^\/]+/B[^\/]+/[^\/]+\Z}i CAMPFIRE_WEBHOOK_URL_REGEX = %r{/rooms/\d+/\d+-[^\/]+/messages\Z}i BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX = %r{/\d+/integrations/[^\/]+/buckets/\d+/chats/\d+/lines\Z}i PERMITTED_SCHEMES = %w[ http https ].freeze PERMITTED_ACTIONS = %w[ card_assigned card_closed card_postponed card_auto_postponed card_board_changed card_published card_reopened card_sent_back_to_triage card_triaged card_unassigned comment_created ].freeze has_secure_token :signing_secret has_many :deliveries, dependent: :delete_all has_one :delinquency_tracker, dependent: :delete belongs_to :account, default: -> { board.account } belongs_to :board serialize :subscribed_actions, type: Array, coder: JSON scope :ordered, -> { order(name: :asc, id: :desc) } scope :active, -> { where(active: true) } after_create :create_delinquency_tracker! normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS } normalizes :url, with: -> { it.strip } validates :name, presence: true validate :validate_url def activate update! active: true unless active? end def deactivate update! active: false end def renderer @renderer ||= ApplicationController.renderer.new(script_name: account.slug, https: !Rails.env.local?) end def for_basecamp? url.match? BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX end def for_campfire? url.match? CAMPFIRE_WEBHOOK_URL_REGEX end def for_slack? url.match? SLACK_WEBHOOK_URL_REGEX end private def validate_url uri = URI.parse(url.presence) if PERMITTED_SCHEMES.exclude?(uri.scheme) errors.add :url, "must use #{PERMITTED_SCHEMES.to_choice_sentence}" end rescue URI::InvalidURIError errors.add :url, "not a URL" end end ================================================ FILE: app/models/zip_file/reader/io.rb ================================================ class ZipFile::Reader::IO def initialize(entry, io) @entry = entry @io = io @extractor = @entry.extractor_from(@io) end def read(length = nil, buffer = nil) return nil if @extractor.eof? data = @extractor.extract(length) return nil if data.nil? if buffer buffer.replace(data) buffer else data end end def eof? @extractor.eof? end def rewind @extractor = @entry.extractor_from(@io) 0 end def size @entry.uncompressed_size end end ================================================ FILE: app/models/zip_file/reader.rb ================================================ class ZipFile::Reader def initialize(io) @io = io @reader = ZipKit::FileReader.read_zip_structure(io: io) rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e raise ZipFile::InvalidFileError, e.message end def read(file_path) entry = @reader.find { |e| e.filename == file_path } raise ArgumentError, "File not found in zip: #{file_path}" unless entry raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.filename.end_with?("/") if block_given? yield ZipFile::Reader::IO.new(entry, @io) else entry.extractor_from(@io).extract end end def glob(pattern) @reader.map(&:filename).select { |name| File.fnmatch(pattern, name) }.sort end def exists?(file_path) @reader.any? { |e| e.filename == file_path } end end ================================================ FILE: app/models/zip_file/remote_io.rb ================================================ class ZipFile::RemoteIO < ZipKit::RemoteIO def initialize(url, ssl_verify_peer: true) super(url) @ssl_verify_peer = ssl_verify_peer end protected def request_range(range) with_http do |http| request = Net::HTTP::Get.new(@uri) request.range = range response = http.request(request) case response.code when "206", "200" response.body else raise "Remote at #{@uri} replied with code #{response.code}" end end end def request_object_size with_http do |http| request = Net::HTTP::Get.new(@uri) request.range = 0..0 response = http.request(request) case response.code when "206" content_range_header_value = response["Content-Range"] content_range_header_value.split("/").last.to_i when "200" response["Content-Length"].to_i else raise "Remote at #{@uri} replied with code #{response.code}" end end end private def with_http http = Net::HTTP.new(@uri.hostname, @uri.port) http.use_ssl = @uri.scheme == "https" http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer http.start { yield http } end end ================================================ FILE: app/models/zip_file/writer.rb ================================================ class ZipFile::Writer attr_reader :byte_size def initialize(io = nil) @entries = [] @byte_size = 0 @output_io = io @streamer = nil @digest = Digest::MD5.new end def stream_to(io) @output_io = io end def write(data) @output_io.write(data) @byte_size += data.bytesize @digest.update(data) data.bytesize end def add_file(path, content = nil, compress: true) @entries << path write_method = compress ? :write_deflated_file : :write_stored_file if block_given? streamer.public_send(write_method, path) { |sink| yield sink } else streamer.public_send(write_method, path) { |sink| sink.write(content) } end end def glob(pattern) @entries.select { |e| File.fnmatch(pattern, e) }.sort end def exists?(path) @entries.include?(path) end def close streamer.close end def checksum Base64.strict_encode64(@digest.digest) end private def streamer @streamer ||= ZipKit::Streamer.new(@output_io) end end ================================================ FILE: app/models/zip_file.rb ================================================ class ZipFile class InvalidFileError < StandardError; end class << self def create_for(attachment, filename:) raise ArgumentError, "No block given" unless block_given? reflection = attachment.record.class.reflect_on_attachment(attachment.name) service_name = reflection.options[:service_name] || ActiveStorage::Blob.service.name service = ActiveStorage::Blob.services.fetch(service_name) if s3_service?(service) create_for_s3(attachment, filename: filename, service: service) { |zip| yield zip } else create_for_disk(attachment, filename: filename) { |zip| yield zip } end end def read_from(blob) raise ArgumentError, "No block given" unless block_given? if s3_service?(blob.service) read_from_s3(blob) { |zip| yield zip } else read_from_disk(blob) { |zip| yield zip } end end private def s3_service?(service) # The S3 service doesn't get loaded in development unless it's used defined?(ActiveStorage::Service::S3Service) && service.is_a?(ActiveStorage::Service::S3Service) end def create_for_s3(attachment, filename:, service:) blob = ActiveStorage::Blob.create_before_direct_upload!( filename: filename, content_type: "application/zip", byte_size: 0, checksum: "pending" ) writer = Writer.new # Use S3's upload_stream directly for write-based streaming. # ActiveStorage's upload method expects a read-based IO, but ZipKit # needs a write-based stream. The TransferManager's upload_stream # yields a writable IO that we can stream directly to. service.send(:upload_stream, key: blob.key, content_type: "application/zip", part_size: 100.megabytes ) do |write_stream| write_stream.binmode writer.stream_to(write_stream) yield writer writer.close end blob.update!(byte_size: writer.byte_size, checksum: writer.checksum) attachment.attach(blob) rescue Aws::S3::MultipartUploadError => e if e.errors.any? raise e.errors.first else raise e end end def create_for_disk(attachment, filename:) tempfile = Tempfile.new([ "export", ".zip" ]) tempfile.binmode writer = Writer.new(tempfile) yield writer writer.close tempfile.rewind attachment.attach(io: tempfile, filename: filename, content_type: "application/zip") ensure tempfile&.close tempfile&.unlink end def read_from_s3(blob) url = blob.url(expires_in: 6.hour) ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer) reader = Reader.new(remote_io) yield reader end def read_from_disk(blob) blob.open do |file| reader = Reader.new(file) yield reader end end end end ================================================ FILE: app/views/account/exports/show.html.erb ================================================ <% if @export.present? %> <% @page_title = "Download Export" %> <% else %> <% @page_title = "Download Expired" %> <% end %> <% content_for :header do %>
<%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<% end %>

<%= @page_title %>

<% if @export.present? %>
Your export is ready. The download should start automatically.
<%= link_to "Download your data", rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", class: "btn btn--link", data: { turbo: false, controller: "auto-click" } %> <% else %>
That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-link" %>.
<% end %>
================================================ FILE: app/views/account/exports/show.json.jbuilder ================================================ json.(@export, :id, :status) json.created_at @export.created_at.utc if @export.completed? && @export.file.attached? json.download_url rails_blob_url(@export.file, disposition: "attachment") end ================================================ FILE: app/views/account/imports/new.html.erb ================================================ <% @page_title = "Import an account" %>

Import a Fizzy account

<% if Fizzy.saas? %>
Running Fizzy on your own server and want us to host on fizzy.do instead?
Export your self-hosted account, then upload the .zip file below.
<% else %>
Ready to host Fizzy on your own server?
Export your fizzy.do account, then upload the .zip file below.
<% end %>
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %> <% end %>

If you run into issues or would like assistance, just send us an email.

<% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/account/imports/show.html.erb ================================================ <% @page_title = "Import status" %> <%= turbo_stream_from @import %>

Import status

<% case @import.status %> <% when "pending", "processing" %>
Your import is in progress. This may take a while for large accounts.
<% when "completed" %>
Your import was successful!
<%= link_to "Go to your account →", landing_url(script_name: @import.account.slug), class: "btn btn--link" %>
<% when "failed" %>
Import failed
<% if @import.failed_due_to_conflict? %>
The account you’re trying to import already exists. Make sure you’re importing a <%= Fizzy.saas? ? "self-hosted" : "fizzy.do" %> account.
<% elsif @import.failed_due_to_invalid_export? %>
The .zip file you uploaded doesn’t look like a Fizzy account export.
<% else %>
This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.
<% end %> <%= link_to "Try again", new_account_import_path, class: "btn" %>
<% end %>
<% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/account/join_codes/edit.html.erb ================================================ <% @page_title = "Change usage limit" %> <% content_for :header do %>
<%= back_link_to "Invite link", account_join_code_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<% end %>

<%= @page_title %>

How many times can this link be used to join the account?

<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> <%= form.number_field :usage_limit, required: true, autofocus: true, in: 0..Account::JoinCode::USAGE_LIMIT_MAX, class: "input center txt-large fit-content font-weight-black txt-align-center", style: "max-inline-size: 8ch", data: { action: "keydown.esc@document->form#cancel focus->form#select" } %> <% if @join_code.errors.any? %>
<% @join_code.errors.full_messages.each do |message| %>

<%= message %>

<% end %>
<% end %>

This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit_in_database %> times.

<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %> Save changes <% end %> <%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
================================================ FILE: app/views/account/join_codes/show.html.erb ================================================ <% @page_title = "Add people" %> <% content_for :header do %>
<%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<% end %>

<%= @page_title %>

Share the link below to invite people to this account

<% url = join_url(code: @join_code.code, script_name: Current.account.slug) %>
<% if Current.user.admin? %> <%= button_to account_join_code_path, method: :delete, class: "btn btn--circle txt-small", data: { turbo_confirm: "Are you sure you want to generate a new link? The previous code will stop working." } do %> <%= icon_tag "refresh" %> Generate a new code <% end %> <% end %>
<%= 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 %> <%= icon_tag "copy-paste" %> Copy invite link <% end %>
<%= tag.button class: "btn", data: { action: "dialog#open" } do %> <%= icon_tag "qr-code" %> Get QR code <% end %>

Scan this code to join <%= Current.account.name %>:

<%= qr_code_image(url) %>

"> This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times (<%= @join_code.active? ? @join_code.usage_limit - @join_code.usage_count : "none" %> remaining) <% if Current.user.admin? %> <%= link_to edit_account_join_code_path, class: @join_code.active? ? "txt-link" : "txt-negative txt-underline" do %> Change limit <% end %> <% end %>

================================================ FILE: app/views/account/join_codes/show.json.jbuilder ================================================ json.(@join_code, :code, :usage_count, :usage_limit) json.url join_url(code: @join_code.code, script_name: Current.account.slug) json.active !!@join_code.active? ================================================ FILE: app/views/account/settings/_cancellation.html.erb ================================================ <% if Current.account.cancellable? && Current.user.owner? %>

Cancel account

Delete your Fizzy account.

Delete your account?

  • All users, including you, will lose access
  • <% if Current.account.try(:active_subscription) %>
  • Your subscription will be canceled
  • <% end %>
  • After 30 days, your data will be permanently deleted
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
<% end %> ================================================ FILE: app/views/account/settings/_entropy.html.erb ================================================

Auto close

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
================================================ FILE: app/views/account/settings/_export.html.erb ================================================

Export account data

Download a complete archive of all account data.

Export all account data

This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.

We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.

<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
================================================ FILE: app/views/account/settings/_name.html.erb ================================================
<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %> <%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %> <% if Current.user.admin? %> <%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %> <%= icon_tag "arrow-right" %> Save changes <% end %> <% end %> <% end %>
================================================ FILE: app/views/account/settings/_user.html.erb ================================================
  • <%= link_to user, class: "txt-ink flex gap-half align-center min-width" do %> <%= avatar_preview_tag user, hidden_for_screen_reader: true %>
    <%= user.name %>
    <%= user.identity.email_address %>
    <% end %> <%= form_with model: user, url: user_role_path(user), data: { controller: "form" }, method: :patch do | form | %> <% end %> <%# FIXME: Move this Current.user check to a stimulus controller that just checks for admin? or the like we so we can cache user list %> <%= button_to user, method: :delete, class: "btn btn--circle btn--negative", disabled: !Current.user.can_administer?(user), data: { turbo_confirm: "Are you sure you want to permanently remove this person from the account?" } do %> <%= icon_tag "minus" %> Remove <%= user.name %> from the account <% end %>
  • ================================================ FILE: app/views/account/settings/_users.html.erb ================================================

    People on this account

    <%= tag.div class: "settings__user-filter flex flex-column gap", data: { controller: "filter navigable-list", action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", navigable_list_focus_on_selection_value: true, navigable_list_actionable_items_value: true } do %>
      <%= render partial: "account/settings/user", collection: users %>
    <%= link_to account_join_code_path, class: "btn btn--link center" do %> <%= icon_tag "add" %> Invite people <% end %> <% end %>
    ================================================ FILE: app/views/account/settings/show.html.erb ================================================ <% @page_title = "Account Settings" %> <% content_for :header do %>

    <%= @page_title %> <% unless Current.user.admin? %>
    Only admins can change these settings
    <% end %>

    <% end %>
    <%= render "account/settings/name", account: @account %> <%= render "account/settings/users", users: @users %>
    <%= render "account/settings/entropy", account: @account %> <%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %> <%= render "account/settings/cancellation" %>
    ================================================ FILE: app/views/account/settings/show.json.jbuilder ================================================ json.(@account, :id, :name, :cards_count) json.created_at @account.created_at.utc json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days ================================================ FILE: app/views/action_text/attachables/_remote_image.html.erb ================================================
    <%= image_tag remote_image.url, skip_pipeline: true, width: remote_image.width, height: remote_image.height %> <% if caption = remote_image.try(:caption) %>
    <%= caption %>
    <% end %>
    ================================================ FILE: app/views/action_text/attachables/_remote_video.html.erb ================================================
    <%= tag.video controls: true, width: remote_video.width, height: remote_video.height do %> <%= tag.source src: remote_video.url, type: remote_video.content_type %> <% end %> <% if caption = remote_video.try(:caption) %>
    <%= caption %>
    <% end %>
    ================================================ FILE: app/views/active_storage/blobs/_blob.html.erb ================================================ <% if blob.representable? %>
    <%= render "active_storage/blobs/web/representation", blob: blob %>
    <% if caption = blob.try(:caption) %> <%= caption %> <% else %> <%= blob.filename %> <% end %> · <%= number_to_human_size blob.byte_size %> · <%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %> Download <% end %>
    <% else %>
    <%= render "active_storage/blobs/web/representation", blob: blob %>
    <% if caption = blob.try(:caption) %> <%= caption %> <% else %> <%= blob.filename %> <% end %>
    <%= number_to_human_size blob.byte_size %> · <%= link_to rails_blob_path(blob, disposition: :attachment), class: "attachment__link", download: blob.filename, title: "Download #{blob.filename}" do %> Download <% end %>
    <% end %> ================================================ FILE: app/views/active_storage/blobs/web/_representation.html.erb ================================================ <% variant = Attachments::VARIANTS[local_assigns[:in_gallery] ? :small : :large] %> <% width = blob.metadata["width"] %> <% height = blob.metadata["height"] %> <% if blob.video? %> <%= tag.video \ src: rails_blob_path(blob), controls: true, preload: :none, style: "aspect-ratio: #{width} / #{height};", width: width, height: height %> <% elsif blob.audio? %> <% elsif blob.variable? %> <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %> <% else %> <%= blob.filename.extension&.downcase.presence || "unknown" %> <% end %> ================================================ FILE: app/views/bar/_bar.html.erb ================================================
    <%= tag.button class: "btn btn--plain", data: { controller: "hotkey", action: "bar#search keydown.k@document->hotkey#click" } do %> Search K <% end %>
    <%= tag.dialog id: "bar-modal", class: "bar__modal", data: { controller: "dialog", dialog_target: "dialog", action: "keydown.esc@document->bar#reset:stop" } do %> <%= turbo_frame_tag "bar_content", data: { bar_target: "turboFrame" } %> <% end %>
    ================================================ FILE: app/views/boards/_access_toggle.html.erb ================================================
  • <%= link_to user, class: "txt-ink flex gap-half align-center min-width" do %> <%= avatar_preview_tag user, hidden_for_screen_reader: true %>
    <%= user.name %>
    <%= user.identity.email_address %>
    <% end %> <%= icon_tag "check", class: "toggler__visible-when-on margin-inline-end" %>
  • ================================================ FILE: app/views/boards/_board.json.jbuilder ================================================ json.cache! board do json.(board, :id, :name, :all_access) json.created_at board.created_at.utc json.auto_postpone_period_in_days board.auto_postpone_period_in_days json.url board_url(board) json.creator board.creator, partial: "users/user", as: :user json.public_url published_board_url(board) if board.published? end ================================================ FILE: app/views/boards/columns/_empty_placeholder.html.erb ================================================
    No cards here
    Drag cards here
    ================================================ FILE: app/views/boards/columns/closeds/show.html.erb ================================================ <% @page_title = "Column: Done" %> <% content_for :header do %>
    <%= link_back_to_board(@board) %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :closed_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :closed_column, @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %> <% else %> <%= render "boards/columns/empty_placeholder" %> <% end %>
    <% end %>
    ================================================ FILE: app/views/boards/columns/closeds/show.json.jbuilder ================================================ json.array! @page.records, partial: "cards/card", as: :card ================================================ FILE: app/views/boards/columns/create.turbo_stream.erb ================================================ <%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %> <%= render "columns/refresh_adjacent_columns", column: @column %> ================================================ FILE: app/views/boards/columns/index.json.jbuilder ================================================ json.array! @columns, partial: "columns/column", as: :column ================================================ FILE: app/views/boards/columns/not_nows/show.html.erb ================================================ <% @page_title = "Column: Not Now" %> <% content_for :header do %>
    <%= link_back_to_board(@board) %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :not_now_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :not_now_column, @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %> <% else %> <%= render "boards/columns/empty_placeholder" %> <% end %>
    <% end %>
    ================================================ FILE: app/views/boards/columns/not_nows/show.json.jbuilder ================================================ json.array! @page.records, partial: "cards/card", as: :card ================================================ FILE: app/views/boards/columns/show.html.erb ================================================ <% @page_title = "Column: #{ @column.name }" %> <% content_for :header do %>
    <%= link_back_to_board(@column.board) %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag @column, :cards do %>
    <% if @page.used? %> <%= with_automatic_pagination dom_id(@column, :cards), @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %> <% else %> <%= render "boards/columns/empty_placeholder" %> <% end %>
    <% end %>
    ================================================ FILE: app/views/boards/columns/show.json.jbuilder ================================================ json.partial! "columns/column", column: @column ================================================ FILE: app/views/boards/columns/streams/show.html.erb ================================================ <% @page_title = "Column: Maybe?" %> <% content_for :header do %>
    <%= link_back_to_board(@board) %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :stream_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :stream_column, @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %> <% else %> <%= render "boards/columns/empty_placeholder" %> <% end %>
    <% end %>
    ================================================ FILE: app/views/boards/columns/streams/show.json.jbuilder ================================================ json.array! @page.records, partial: "cards/card", as: :card ================================================ FILE: app/views/boards/columns/update.turbo_stream.erb ================================================ <%= turbo_stream.replace(dom_id(@column), partial: "boards/show/column", method: :morph, locals: { column: @column }) %> ================================================ FILE: app/views/boards/edit/_auto_close.html.erb ================================================ <%= turbo_frame_tag board, :entropy do %>

    Auto close

    Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…

    <%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
    <% end %> ================================================ FILE: app/views/boards/edit/_delete.html.erb ================================================

    Delete this board?

    Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.

    <%= button_to board_path(board), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %> Delete board <% end %>
    ================================================ FILE: app/views/boards/edit/_name.html.erb ================================================
    ================================================ FILE: app/views/boards/edit/_publication.html.erb ================================================ <%= turbo_frame_tag @board, :publication do %>

    Public link

    Turn on the Public link to share this board with anyone in the world. They won’t need to log in and they won’t be able to see anything else in Fizzy.
    <% if board.published? %>
    <%= form_with url: board_publication_path(board), method: :delete, class: "flex-inline center justify-between gap", data: { controller: "form" } do |form| %> <% end %>
    <%= text_field_tag :publication_url, published_board_url(board), readonly: true, class: "full-width input fill-white" %>
    <%= button_to_copy_to_clipboard(published_board_url(board)) do %> <%= icon_tag "copy-paste" %> Copy public link <% end %>
    Add an optional description to the public page
    <%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %> <%= form.rich_textarea :public_description, class: "lexxy-content txt-small", placeholder: "Add a public note about this board…", data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" }, readonly: !Current.user.can_administer_board?(@board) %> <%= form.button "Save changes", type: :submit, class: "btn txt-small", title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })" %> <% end %>
    <% else %>
    <%= form_with url: board_publication_path(board), method: :post, class: "flex-inline center justify-between gap", data: { controller: "form" } do |form| %> <% end %>
    <% end %> <% end %> ================================================ FILE: app/views/boards/edit/_users.html.erb ================================================ <% disabled = !Current.user.can_administer_board?(board) %>

    Who can access this board?

    <%= access_menu_tag board, class: "settings__user-filter unpad margin-none" do %>
  • <%= icon_tag "everyone" %> Everyone
    Everyone
  • <%= button_tag "Select all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" }, disabled: %> · <%= button_tag "Select none", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" }, disabled: %>
      <%= access_toggles_for selected_users, selected: true, disabled: %> <%= access_toggles_for unselected_users, selected: false, disabled: %>
    <% end %> ================================================ FILE: app/views/boards/edit.html.erb ================================================ <% @page_title = "Board Settings" %> <% content_for :header do %>
    <%= link_back_to_board(@board) %>

    <%= @page_title %>
    <% unless Current.user.can_administer_board?(@board) %>
    Only admins can change these settings
    <% end %>

    <% end %>
    <%= form_with model: @board, class: "display-contents", data: { controller: "form boards-form bridge--form", boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You won’t be able to get back in unless someone invites you.", action: "turbo:submit-start->boards-form#submitWithWarning" } do |form| %> <%= render "boards/edit/name", form: form, board: @board %> <%= render "boards/edit/users", board: @board, selected_users: @selected_users, unselected_users: @unselected_users, form: form %> <%= link_to "Cancel and go back", @board, data: { form_target: "cancel", turbo_frame: "_top" }, hidden: true %> <% end %>
    <%= render "boards/edit/auto_close", board: @board %> <%= render "boards/edit/publication", board: @board %> <%= render "boards/edit/delete", board: @board if Current.user.can_administer_board?(@board) %>
    ================================================ FILE: app/views/boards/entropies/update.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @board, :entropy ], partial: "boards/edit/auto_close", locals:{ board: @board }) %> <%= turbo_stream_flash(notice: "Saved") %> ================================================ FILE: app/views/boards/index.json.jbuilder ================================================ json.array! @page.records, partial: "boards/board", as: :board ================================================ FILE: app/views/boards/involvements/update.html.erb ================================================ <%= access_involvement_advance_button(@board, Current.user, show_watchers: params[:show_watchers] == "true", icon_only: params[:icon_only] == "true") %> ================================================ FILE: app/views/boards/new.html.erb ================================================ <% @page_title = "Create a new board" %> <% @body_class = "compact-on-touch" %>
    <%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

    <%= @page_title %>

    <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can’t be blank" } %> <%= link_to "Cancel and go back", root_path, data: { form_target: "cancel", turbo_frame: "_top" }, hidden: true %> <% end %>
    ================================================ FILE: app/views/boards/publications/create.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @board, :publication ], partial: "boards/edit/publication", locals:{ board: @board }) %> <%= turbo_stream_flash(notice: "Saved") %> ================================================ FILE: app/views/boards/publications/destroy.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @board, :publication ], partial: "boards/edit/publication", locals:{ board: @board }) %> <%= turbo_stream_flash(notice: "Saved") %> ================================================ FILE: app/views/boards/show/_closed.html.erb ================================================ <%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);", data: { card_hotkeys_disabled: true, drag_and_strum_target: "container", collapsible_columns_target: "column", action: "focus->collapsible-columns#focusOnColumn" } do %>
    <%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %> <%= render "boards/show/menu/maximize", column_path: board_columns_closed_path(board) %>
    <%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %> <% end %> ================================================ FILE: app/views/boards/show/_column.html.erb ================================================ <%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), card_color: column.color.to_s, class: "cards--doing", style: "--card-color: #{column.color};", data: { drag_and_strum_target: "container", collapsible_columns_target: "column", controller: "clicker", action: "turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn dialog:show->collapsible-columns#frameColumnOnMobile" } do %>
    <%= render "boards/show/menu/column", column: column %> <%= render "boards/show/expander", title: column.name, count: column.cards.active.count, column_id: dom_id(column) %> <%= link_to board_column_path(column.board, column), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> <%= icon_tag "grid", class: "translucent" %> Maximize column <% end %>
    <%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %> <% end %> ================================================ FILE: app/views/boards/show/_columns.html.erb ================================================ <%= tag.div class: "card-columns hide-scrollbar", data: { controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", collapsible_columns_board_value: board.id, collapsible_columns_collapsed_class: "is-collapsed", collapsible_columns_expanded_class: "is-expanded", collapsible_columns_no_transitions_class: "no-transitions", collapsible_columns_title_not_visible_class: "is-off-screen", navigable_list_supports_vertical_navigation_value: false, navigable_list_has_nested_navigation_value: true, navigable_list_prevent_handled_keys_value: true, navigable_list_auto_select_value: false, navigable_list_auto_scroll_value: false, card_hotkeys_navigable_list_outlet: ".cards__transition-container", native_prevent_pull_to_refresh: true, action: " keydown->navigable-list#navigate keydown->card-hotkeys#handleKeydown turbo:morph@document->card-hotkeys#handleMorphComplete dragstart->drag-and-drop#dragStart dragover->drag-and-drop#dragOver dragenter->drag-and-strum#dragEnter drop->drag-and-drop#drop dragend->drag-and-drop#dragEnd click@document->navigable-list#deselectWhenClickingOutside" } do %>
    <%= render "boards/show/not_now", board: board %>
    <%= render "boards/show/stream", board: board, page: page %>
    <%= render partial: "boards/show/column", collection: board.columns.sorted, cached: ->(column){ [ column, column.leftmost?, column.rightmost? ] } %> <%= render "boards/show/closed", board: board %> <%= render "boards/show/menu/columns", board: board %>
    <% end %> ================================================ FILE: app/views/boards/show/_expander.html.erb ================================================ <%= yield if block_given? %> ================================================ FILE: app/views/boards/show/_filtered_cards.html.erb ================================================
    <%= with_automatic_pagination :filtered_cards_paginated_container, page do %> <%= render "cards/display/previews", cards: page.records, draggable: true %> <% end %>
    No cards match this filter
    ================================================ FILE: app/views/boards/show/_not_now.html.erb ================================================ <%= column_tag id: "not-now", name: "Not Now", drop_url: columns_card_drops_not_now_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);", data: { card_hotkeys_disabled: true, collapsible_columns_target: "column", drag_and_strum_target: "container", action: "focus->collapsible-columns#focusOnColumn" } do %>
    <%= render "boards/show/expander", title: "Not Now", count: board.cards.postponed.count, column_id: "not-now" %> <%= render "boards/show/menu/maximize", column_path: board_columns_not_now_path(board) %>
    <%= column_frame_tag :not_now_column, src: board_columns_not_now_path(board) %> <% end %> ================================================ FILE: app/views/boards/show/_stream.html.erb ================================================ <%= column_tag id: "maybe", name: "Maybe?", drop_url: columns_card_drops_stream_path("__id__"), collapsed: false, selected: "true", class: "cards--maybe", data: { drag_and_strum_target: "container", collapsible_columns_target: "column maybeColumn", action: "focus->collapsible-columns#focusOnColumn" } do %>
    <%= render "boards/show/expander", title: "Maybe?", count: board.cards.awaiting_triage.count, column_id: "maybe" %> <%= render "boards/show/menu/maximize", column_path: board_columns_stream_path(board) %>
    <%= render "columns/show/add_card_button", board: board %>
    <% if page.used? %> <%= with_automatic_pagination "maybe", page do %> <%= render "cards/display/previews", cards: page.records, draggable: true %> <% end %> <% end %>
    <% end %> ================================================ FILE: app/views/boards/show/menu/_column.html.erb ================================================
    <%= render "boards/show/menu/column_form", board: column.board, column: column, label: "Save changes" %>
    ================================================ FILE: app/views/boards/show/menu/_column_form.html.erb ================================================ <%= form_with model: [board, column], data: { controller: "form", action: "turbo:submit-end->dialog#close turbo:submit-end->form#reset keydown->dialog#captureKey" } do |form| %> <%= form.text_field :name, class: "input", placeholder: "Name this column", value: column.name, required: true, autocomplete: "off", pattern: ".*\\S.*", title: "Column name cannot be blank", data: { action: "focus->form#select" } %>
    <% Color::COLORS.each do |color| %> <% end %>
    <%= form.submit label, class: "btn btn--link" %> <% end %> ================================================ FILE: app/views/boards/show/menu/_columns.html.erb ================================================
    <%= render "boards/show/menu/column_form", board: board, column: Column.new, label: "Add column" %>
    ================================================ FILE: app/views/boards/show/menu/_maximize.html.erb ================================================ <%= link_to column_path, class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> <%= icon_tag "grid", class: "translucent" %> Expand column <% end %> ================================================ FILE: app/views/boards/show.html.erb ================================================ <% @page_title = @board.name %> <% @body_class = "contained-scrolling" %> <% turbo_exempts_page_from_cache %> <%= turbo_stream_from @board %> <% content_for :header do %>
    <%= link_to_webhooks(@board) if Current.user.admin? %>

    <%= @board.name %>

    <%= link_to_edit_board @board %>
    <% end %> <%= render "filters/settings", filter_url: board_path(@board), user_filtering: @user_filtering, no_filtering_url: board_path(@board) do |form| %> <%= hidden_field_tag "board_ids[]", @board.id %> <% end %> <%= turbo_frame_tag :cards_container do %> <% if @filter.used?(ignore_boards: true) %> <%= render "boards/show/filtered_cards", page: @page %> <% else %> <%= render "columns/show/add_card_button", board: @board %> <%= render "boards/show/columns", page: @page, board: @board %> <% end %> <% end %> <%= bridged_share_url_button(bridge_share_board_description(@board)) %> ================================================ FILE: app/views/boards/show.json.jbuilder ================================================ json.partial! "boards/board", board: @board ================================================ FILE: app/views/cards/_broadcasts.html.erb ================================================ <% if filter.boards.any? %> <% filter.boards.each do |board| %> <%= turbo_stream_from board %> <% end %> <% else %> <%= turbo_stream_from [ Current.account, :all_boards ] %> <% end %> ================================================ FILE: app/views/cards/_card.json.jbuilder ================================================ json.cache! card do json.(card, :id, :number, :title, :status) json.description card.description.to_plain_text json.description_html card.description.to_s json.image_url card.image.presence && url_for(card.image) json.has_attachments card.has_attachments? json.tags card.tags.pluck(:title).sort json.closed card.closed? json.postponed card.postponed? json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc json.url card_url(card) json.board card.board, partial: "boards/board", as: :board json.column card.column, partial: "columns/column", as: :column if card.column json.creator card.creator, partial: "users/user", as: :user json.assignees card.assignees.limit(5), partial: "users/user", as: :user json.has_more_assignees card.assignees.size > 5 json.comments_url card_comments_url(card) json.reactions_url card_reactions_url(card) end ================================================ FILE: app/views/cards/_container.html.erb ================================================
    <% cache card do %>
    <%= render "cards/container/gild", card: card if card.published? && !card.closed? %> <%= render "cards/container/image", card: card %>
    <%= card_article_tag card, class: "card" do %>
    <%= render "cards/display/perma/board", card: card %> <%= render "cards/display/perma/tags", card: card %>
    <%= render "cards/container/content", card: card %> <%= render "cards/display/perma/steps", card: card %>
    <% if card.published? %> <%= render "cards/triage/columns", card: card %> <% end %> <%= render "cards/display/common/stamp", card: card %>
    <%= render "cards/display/perma/meta", card: card %> <%= render "cards/display/perma/background", card: card %> <%= render "reactions/reactions", reactable: card %>
    <% end %> <% if card.entropic? %> <%= render "cards/display/preview/bubble", card: card %> <% end %>
    <% end %> <% if card.published? %> <%= render "cards/container/footer/published", card: card %> <% end %>
    ================================================ FILE: app/views/cards/_delete.html.erb ================================================

    Delete this card?

    Are you sure you want to permanently delete this card?

    <%= button_to card_path(card), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %> Delete card <% end %>
    ================================================ FILE: app/views/cards/_messages.html.erb ================================================ <%= messages_tag(card) do %> <% if card.published? %> <%= render partial: "cards/comments/comment", collection: card.comments.preloaded.chronologically, cached: true %> <% if Fizzy.saas? %> <%= render "cards/comments/saas/new", card: card %> <% else %> <%= render "cards/comments/new", card: card %> <% end %> <%= render "cards/comments/watchers", card: card %> <% end %> <% if Current.user.can_administer_card?(card) %>

    <%= render "cards/delete", card: card %>
    <% end %> <% end %> ================================================ FILE: app/views/cards/assignments/_user.html.erb ================================================ ================================================ FILE: app/views/cards/assignments/create.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @card, :meta ], partial: "/cards/display/perma/meta", method: "morph", locals: { card: @card.reload }) %> ================================================ FILE: app/views/cards/assignments/new.html.erb ================================================ <%= turbo_frame_tag @card, :assignment do %> <%= tag.div class: "max-width full-width", data: { action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset", controller: "filter navigable-list assignment-limit", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, navigable_list_actionable_items_value: true, assignment_limit_limit_value: Assignment::LIMIT, assignment_limit_count_value: @card.assignments.count } do %>
    Assign this to… a
    <%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %> <% end %> <% end %> ================================================ FILE: app/views/cards/boards/edit.html.erb ================================================ <%= turbo_frame_tag "board_picker" do %> <%= tag.div class: "max-width full-width", data: { action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset", controller: "filter navigable-list", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, navigable_list_actionable_items_value: true } do %> <%= filter_title "Move this card to…" %> <%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half font-weight-normal", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> ================================================ FILE: app/views/cards/closures/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %> <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> ================================================ FILE: app/views/cards/closures/destroy.turbo_stream.erb ================================================ <%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %> <% if @card.column %> <%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %> <% elsif @card.awaiting_triage? %> <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> ================================================ FILE: app/views/cards/columns/_column.html.erb ================================================ <%= render "cards/display/previews", cards: column.cards, draggable: draggable %> ================================================ FILE: app/views/cards/columns/edit.html.erb ================================================ <%= turbo_frame_tag @card, :columns do %>
    Choose a column for this card <%= button_to "Not now", card_not_now_path(@card), class: [ "card__column-name btn", { "card__column-name--current": @card.postponed? } ], style: "--column-color: var(--color-card-complete)", disabled: @card.postponed?, role: "radio", aria: { checked: @card.postponed? }, data: { scroll_to_target: @card.postponed? ? "target" : nil }, form_class: "flex gap-half" %> <%= button_to "Maybe?", card_triage_path(@card), method: :delete, class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ], style: "--column-color: var(--color-card-default)", disabled: @card.awaiting_triage?, role: "radio", aria: { checked: @card.awaiting_triage? }, data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil }, form_class: "flex gap-half" %> <% @columns.each do |column| %> <%= button_to_set_column @card, column %> <% end %> <%= button_to card_closure_path(@card), class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ], style: "--column-color: var(--color-card-complete)", disabled: @card.closed?, role: "radio", aria: { checked: @card.closed? }, data: { scroll_to_target: @card.closed? ? "target" : nil }, form_class: "flex gap-half" do %> <%= icon_tag "check", class: "icon--mobile-only" %> Done <% end %>
    <% end %> ================================================ FILE: app/views/cards/comments/_comment.html.erb ================================================ <% cache comment do %> <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%= turbo_frame_tag comment, :container, class: { "comment-by-system": comment.creator.system? } do %> <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>

    <%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %> <%= link_to comment, class: "comment__permalink-title", data: { turbo_frame: "_top" } do %> <%= local_datetime_tag comment.created_at, style: :agoorweekday %>, <%= local_datetime_tag comment.created_at, style: :time %> <% end %>

    <%= link_to edit_card_comment_path(comment.card, comment), class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %> <%= icon_tag "menu-dots-horizontal" %> Edit this comment <% end %>
    <%= comment.body %>
    <%= render "reactions/reactions", reactable: comment %>
    <% end %> <% end %> ================================================ FILE: app/views/cards/comments/_comment.json.jbuilder ================================================ json.cache! comment do json.(comment, :id) json.created_at comment.created_at.utc json.updated_at comment.updated_at.utc json.body do json.plain_text comment.body.to_plain_text json.html comment.body.to_s end json.creator comment.creator, partial: "users/user", as: :user json.card do json.id comment.card_id json.url card_url(comment.card) end json.reactions_url card_comment_reactions_url(comment.card, comment) json.url card_comment_url(comment.card, comment) end ================================================ FILE: app/views/cards/comments/_new.html.erb ================================================
    <%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width", data: { controller: "form local-save", local_save_key_value: "comment-#{card.id}", action: "turbo:submit-end->local-save#submit turbo:submit-end->form#blurActiveInput keydown.ctrl+enter->form#debouncedSubmit:prevent keydown.meta+enter->form#debouncedSubmit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card), data: { local_save_target: "input", action: "lexxy:change->form#disableSubmitWhenInvalid lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent" } do %> <%= general_prompts(@card.board) %> <% end %> <%= form.button class: "comment__submit btn btn--reversed", title: "Post this comment (#{ hotkey_label(["ctrl", "enter"]) })", data: { form_target: "submit" }, disabled: true do %> Post <% end %> <% end %>
    ================================================ FILE: app/views/cards/comments/_watchers.html.erb ================================================
    Subscribers

    <%= pluralize(card.watchers.active.count, "person") %> will be notified when someone comments on this.

    <% card.watchers.active.alphabetically.each do |watcher| %> <%= avatar_tag watcher %> <% end %>
    ================================================ FILE: app/views/cards/comments/create.turbo_stream.erb ================================================ <%= turbo_stream.before [ @card, :new_comment ], partial: "cards/comments/comment", locals: { comment: @comment } %> <%= turbo_stream.update [ @card, :new_comment ], partial: "cards/comments/new", locals: { card: @card } %> ================================================ FILE: app/views/cards/comments/destroy.turbo_stream.erb ================================================ <%= turbo_stream.remove [ @comment, :container ] %> ================================================ FILE: app/views/cards/comments/edit.html.erb ================================================ <%= turbo_frame_tag @comment, :container do %>
    <%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width", data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %> <%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %> <%= general_prompts(@card.board) %> <% end %>
    <%= form.button class: "btn btn--reversed", type: :submit, title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })" do %> Save <% end %> <%= link_to card_comment_path(@card, @comment), class: "btn", data: { form_target: "cancel" },title: "Cancel (#{ hotkey_label(["esc"]) })" do %> Cancel <% end %> <%= tag.button type: :submit, class: "btn btn--negative flex-item-justify-end", form: dom_id(@comment, :delete_form), data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %> <%= icon_tag "trash" %> Delete <% end %>
    <% end %> <%= form_with url: card_comment_path(@card, @comment), method: :delete, id: dom_id(@comment, :delete_form), data: { turbo_frame: "_top" } %>
    <% end %> ================================================ FILE: app/views/cards/comments/index.json.jbuilder ================================================ json.array! @page.records, partial: "cards/comments/comment", as: :comment ================================================ FILE: app/views/cards/comments/show.html.erb ================================================ <%= render "cards/comments/comment", comment: @comment %> ================================================ FILE: app/views/cards/comments/show.json.jbuilder ================================================ json.partial! "cards/comments/comment", comment: @comment ================================================ FILE: app/views/cards/comments/update.turbo_stream.erb ================================================ <%= turbo_stream.replace [ @comment, :container ], partial: "cards/comments/comment", locals: { comment: @comment } %> ================================================ FILE: app/views/cards/container/_closure.html.erb ================================================
    <% if card.closed? %>
    Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> Undo <% end %> <%= bridged_button_to_board(card.board) %>
    <% else %>
    <%= render "cards/container/closure_buttons", card: card %>
    <% if card.entropic? && card.open? && !card.postponed? %>
    Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if there’s no activity.
    <% end %> <% end %>
    ================================================ FILE: app/views/cards/container/_closure_buttons.html.erb ================================================
    <%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless hide-on-native", data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: ("button" unless card.postponed?), bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done d <% end %> <%= bridged_button_to_board(card.board) %>
    ================================================ FILE: app/views/cards/container/_content.html.erb ================================================ <% if card.published? %>
    <%= turbo_frame_tag card, :edit do %> <%# When canceling an edit (with the ESC key), restore the button area to show "Edit" instead of "Save changes". %> <%= turbo_stream.replace dom_id(card, :card_closure_toggle) do %> <%= render "cards/container/closure", card: card %> <% end %> <%= render "cards/container/content_display", card: card %> <% end %>
    <% else %> <%= form_with model: card, id: "card_form", data: { controller: "autoresize auto-save" } do |form| %>

    <%= 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 autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar", autofocus: card.title.blank?, rows: 1, dir: "auto", maxlength: 255, data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent" } %> <% end %>

    <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %> <%= general_prompts(card.board) %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/cards/container/_content_display.html.erb ================================================

    <%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>

    <% unless card.description.blank? %>
    <%= card.description %>
    <% end %> ================================================ FILE: app/views/cards/container/_gild.html.erb ================================================ <% if card.golden? %> <%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Demote to normal" } do %> <%= icon_tag "golden-ticket" %> Demote to normal (shift+g) <% end %> <% else %> <%= button_to card_goldness_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Promote to Golden Ticket" } do %> <%= icon_tag "golden-ticket" %> Promote to Golden Ticket (shift+g) <% end %> <% end %> ================================================ FILE: app/views/cards/container/_image.html.erb ================================================ <% if card.image.attached? %> <%= button_to card_image_path(card), method: :delete, class: "btn hide-on-native", data: { controller: "tooltip" } do %> <%= icon_tag "picture-remove" %> Remove background image <% end %> <% elsif !card.closed? %> <%= form_with model: card, data: { controller: "form" } do |form| %> <% end %> <% end %> ================================================ FILE: app/views/cards/container/_save_button.html.erb ================================================
    <%= button_tag type: :submit, form: dom_id(card, :edit_form), class: "btn borderless", title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })", data: { controller: "hotkey", bridge__form_target: "submit", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %> Save changes <% end %>
    ================================================ FILE: app/views/cards/container/footer/_create.html.erb ================================================
    <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", form: { data: { controller: "form bridge--form" } }, data: { form_target: "submit", bridge__form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> Create card <% end %> <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> Create and add another <% end %>
    <%= render "cards/container/footer/saas/storage_limit_notice" if Fizzy.saas? %>
    ================================================ FILE: app/views/cards/container/footer/_published.html.erb ================================================ <%# FIXME: Let's move this aside outside of the card container section so these frames don't reload/flicker when card is replaced %>
    <%= turbo_frame_tag card, :watch, src: card_watch_path(card), target: "_top", refresh: :morph do %> <%= button_to card_watch_path(card), class: "btn", data: { controller: "tooltip" } do %> <%= icon_tag "bell-off" %> Watch this <% end %> <% end %> <%= turbo_frame_tag card, :pin, src: card_pin_path(card), refresh: :morph do %> <%= button_to card_pin_path(card), class: "btn", data: { controller: "tooltip" } do %> <%= icon_tag "unpinned" %> Pin this card <% end %> <% end %>
    <%= render "cards/container/closure", card: card %> ================================================ FILE: app/views/cards/display/_preview.html.erb ================================================ <% draggable = local_assigns.fetch(:draggable, false) && card.published? %> <% card_data = { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" } %> <% if card.open? %> <% card_data[:card_not_now_url] = card_not_now_path(card) %> <% card_data[:card_closure_url] = card_closure_path(card) %> <% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %> <% card_data[:card_assign_to_self_url] = card_self_assignment_path(card) %> <% end %> <%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
    <%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> <%= card.title %> <% end %>
    <%= render "cards/display/preview/board", card: card %> <%= render "cards/display/preview/tags", card: card %> <%= render "cards/display/preview/steps", card: card %> <%= icon_tag "attachment", class: "card__attachments-indicator translucent" if card.has_attachments? %> <% if card.triaged? %> <%= card.column.name %> <% end %>

    <%= card_html_title(card) %>

    <%= render "cards/display/preview/columns", card: card %> <%= render "cards/display/common/stamp", card: card %>
    <%= render "cards/display/preview/meta", card: card, preview: true %>
    <%= render "cards/display/preview/boosts", card: card %> <%= render "cards/display/preview/comments", card: card %>
    <%= render "cards/display/common/background", card: card %>
    <% if card.entropic? %> <%= render "cards/display/preview/bubble", card: card %> <% end %> <% end %> ================================================ FILE: app/views/cards/display/_previews.html.erb ================================================ <%= render partial: "cards/display/preview", collection: cards, as: :card, locals: { draggable: local_assigns.fetch(:draggable, false) }, cached: true %> ================================================ FILE: app/views/cards/display/_public_preview.html.erb ================================================ <%= card_article_tag card, class: "card" do %>
    Card number <%= card.number %> <%= card.board.name %>
    <%= render "cards/display/preview/tags", card: card %>

    <%= card_html_title(card) %>

    <%= render "cards/display/public_preview/columns", card: card if card.triaged? %> <%= render "cards/display/common/stamp", card: card %>
    <%= render "cards/display/public_preview/meta", card: card %>
    <%= render "cards/display/common/background", card: card %> <%= link_to published_card_path(card), class: "card__link", title: card_title_tag(card), data: { turbo_frame: "_top" } do %> <%= card.title %> <% end %> <% if card.entropic? %> <%= render "cards/display/preview/bubble", card: card %> <% end %> <% end %> ================================================ FILE: app/views/cards/display/_public_previews.html.erb ================================================ <%= render partial: "cards/display/public_preview", collection: cards, as: :card, cached: true %> ================================================ FILE: app/views/cards/display/common/_assignees.html.erb ================================================
    > <% unless local_assigns[:preview] %> <%= button_to "Assign to me", card_self_assignment_path(card), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %> <% end %> <%= yield %>
    ================================================ FILE: app/views/cards/display/common/_background.html.erb ================================================ <% if card.image.present? %>
    <%= image_tag card.image.presence || "", size: 120, data: { upload_preview_target: "image" } %>
    <%= yield %> <% end %> ================================================ FILE: app/views/cards/display/common/_board.html.erb ================================================
    Card number <%= card.number %>
    <%= card.board.name %> <%= yield %>
    ================================================ FILE: app/views/cards/display/common/_meta.html.erb ================================================
    <%= avatar_tag card.creator, tabindex: (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>
    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> By <%= card.creator.familiar_name %> <%= icon_tag "refresh--meta" %> Updated <%= local_datetime_tag(card.last_active_at, style: :daysago) %> <%= icon_tag "arrow-right" if card.assignees.any? %> <%= card.assignees.any? ? "Assigned to" : "Not assigned" %> <%= card.assignees.map { |assignee| "#{h assignee.familiar_name}" }.to_sentence.html_safe %>
    <%= yield %>
    ================================================ FILE: app/views/cards/display/common/_stamp.html.erb ================================================ <% if card.postponed? %> <%= tag.div class: token_list("card__closed", "card__closed--system": card.postponed_by&.system?), data: { controller: "bridge--stamp", bridge__stamp_scope_selector_value: ".card-perma", bridge_title: "Not Now", bridge_description: card.postponed_at.strftime("%b %d, %Y") } do %> Not Now <%= card.postponed_at.strftime("%b %d, %Y") %> <% end %> <% end %> <% if card.closed? %> <%= tag.div class: "card__closed", data: { controller: "bridge--stamp", bridge__stamp_scope_selector_value: ".card-perma", bridge_title: "Done", bridge_description: card.closed_at.strftime("%b %d, %Y") } do %> Done <%= card.closed_at.strftime("%b %d, %Y") %> <% end %> <% end %> ================================================ FILE: app/views/cards/display/mini/_assignees.html.erb ================================================ <%= turbo_frame_tag card, :assignees do %> <% card.assignees.each do |assignee| %> <%= avatar_tag assignee %> <% end %> <% end %> ================================================ FILE: app/views/cards/display/mini/_meta.html.erb ================================================ <%= render "cards/display/common/meta", card: card do %> <%= render "cards/display/mini/assignees", card: card %> <% end %> ================================================ FILE: app/views/cards/display/mini/_tags.html.erb ================================================ <%= render "cards/display/preview/tags", card: card %> ================================================ FILE: app/views/cards/display/perma/_assignees.html.erb ================================================ <%= render "cards/display/common/assignees", card: card do %> <%= turbo_frame_tag card, :assignment, src: new_card_assignment_path(card), loading: :lazy, refresh: "morph" %> <% end %> ================================================ FILE: app/views/cards/display/perma/_background.html.erb ================================================ <%= render "cards/display/common/background", card: card do %> <%= link_to card.image.presence, class: "card__zoom-bg-btn btn", data: { controller: "tooltip", lightbox_target: "image" } do %> <%= icon_tag "picture-zoom" %> Zoom background image <% end %> <% end %> ================================================ FILE: app/views/cards/display/perma/_board.html.erb ================================================
    <%= render "cards/display/common/board", card: card do %> <%= icon_tag "caret-down", class: "txt-xx-small", hidden: card.closed? %> <% end %> <%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %>
    ================================================ FILE: app/views/cards/display/perma/_meta.html.erb ================================================ <%= render "cards/display/common/meta", card: card do %> <%= render "cards/display/perma/assignees", card: card %> <% end %> ================================================ FILE: app/views/cards/display/perma/_steps.html.erb ================================================
      <%= render partial: "cards/steps/step", collection: card.steps, as: :step %> <% unless card.closed? %>
    1. <%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit turbo:submit-end->form#reset" } do |form| %> <%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a step" } %> <% end %>
    2. <% end %>
    ================================================ FILE: app/views/cards/display/perma/_tags.html.erb ================================================
    > <%= turbo_frame_tag card, :tagging, src: new_card_tagging_path(card), loading: :lazy, refresh: :morph %>
    <% if card.tags.any? %>
    <% card.tags.each_with_index do |tag, index| %> <%= link_to cards_path(board_ids: [ card.board ], tag_ids: [ tag.id ]), class: "card__tag btn btn--plain min-width txt-uppercase fill-transparent" do %> <%= tag.title %> <% end %><%= "," unless index == card.tags.size - 1 %> <% end %>
    <% end %>
    ================================================ FILE: app/views/cards/display/preview/_assignees.html.erb ================================================ <%= render "cards/display/common/assignees", card: card, preview: true %> ================================================ FILE: app/views/cards/display/preview/_board.html.erb ================================================
    <%= render "cards/display/common/board", card: card %>
    ================================================ FILE: app/views/cards/display/preview/_boosts.html.erb ================================================ <% boosts = card.reactions %> <% if boosts.any? %>
    <%= image_tag "boost-color.svg", aria: { hidden: true } %> <%= boosts.size %>
    <% end %> ================================================ FILE: app/views/cards/display/preview/_bubble.html.erb ================================================ <%= tag.div \ id: dom_id(card, "bubble"), hidden: true, class: "bubble", data: { controller: "bubble", action: "turbo:morph-element->bubble#update:self", bubble_entropy_value: entropy_bubble_options_for(card).to_json, bubble_stalled_value: stalled_bubble_options_for(card)&.to_json } do %> " fill="transparent" d="M 20,100 A 80,80 0 0,1 180,100"/> " startOffset="50%" dominant-baseline="middle" data-bubble-target="top"> " d="M 20,0 A 80,80 0 0,0 180,0" fill="transparent"/> " startOffset="50%" dominant-baseline="middle" data-bubble-target="bottom"> <% end %> ================================================ FILE: app/views/cards/display/preview/_columns.html.erb ================================================
    <% card.board.columns.each do |column| %> <%= tag.span column.name, class: ["card__column-name btn overflow-ellipsis", { "card__column-name--current": column == card.column }] %> <% end %>
    ================================================ FILE: app/views/cards/display/preview/_comments.html.erb ================================================ <% comments = card.comments.by_user %> <% if comments.any? %>
    <%= icon_tag "comment" %> <%= comments.count %>
    <% end %> ================================================ FILE: app/views/cards/display/preview/_meta.html.erb ================================================
    <%= avatar_tag card.creator, tabindex: (local_assigns.key?(:preview) && local_assigns[:preview]) ? -1 : 0 %>
    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> <%= card.creator.familiar_name %> <%= icon_tag "refresh--meta", aria: { label: "Last updated" } %> <%= local_datetime_tag(card.last_active_at, style: :daysago) %> <%= icon_tag("arrow-right", aria: { label: "Assigned to" }) if card.assignees.any? %> <%= card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence(two_words_connector: " / ", last_word_connector: " / ").html_safe %>
    <%= render "cards/display/preview/assignees", card: card %>
    ================================================ FILE: app/views/cards/display/preview/_people.html.erb ================================================ <%= render "cards/display/common/people", card: card do%> <%= render "cards/display/preview/assignees", card: card, preview: true %> <% end %> ================================================ FILE: app/views/cards/display/preview/_steps.html.erb ================================================ <% if card.steps.any? %>
    <%= icon_tag "check" %> <%= "#{card.steps.completed.count}/#{card.steps.count}" %>
    <% end %> ================================================ FILE: app/views/cards/display/preview/_tags.html.erb ================================================ <% if card.tags.any? %>
    <%= icon_tag "tag-outline", class: "translucent" %>
    <% card.tags.each_with_index do |tag, index| %> <%= tag.title %><%= "," unless index == card.tags.size - 1 %> <% end %>
    <% end %> ================================================ FILE: app/views/cards/display/public_preview/_columns.html.erb ================================================
    <% card.board.columns.each do |column| %> <%= tag.div column.name, class: ["card__column-name overflow-ellipsis flex align-center gap-half btn non-clickable no-hover", { "card__column-name--current": column == card.column }] %> <% end %>
    ================================================ FILE: app/views/cards/display/public_preview/_meta.html.erb ================================================
    <%= avatar_preview_tag card.creator %>
    <%= card_drafted_or_added(card) %> <%= local_datetime_tag(card.created_at, style: :daysago) %> By <%= card.creator.familiar_name %> Updated <%= local_datetime_tag(card.last_active_at, style: :daysago) %> <%= "Assigned to" if card.assignees.any? %> <%= card.assignees.map { |assignee| "#{h assignee.familiar_name}" }.to_sentence.html_safe %>
    <% card.assignees.each do |assignee| %> <%= avatar_preview_tag assignee %> <% end %>
    ================================================ FILE: app/views/cards/drafts/_container.html.erb ================================================
    <% cache card do %>
    <%= render "cards/container/image", card: card %>
    <%= card_article_tag card, class: "card" do %>
    <%= render "cards/display/perma/board", card: card %> <%= render "cards/display/perma/tags", card: card %>
    <%= render "cards/container/content", card: card %> <%= render "cards/display/perma/steps", card: card %>
    <%= render "cards/display/perma/meta", card: card %> <%= render "cards/display/perma/background", card: card %>
    <% end %>
    <% end %> <% if Fizzy.saas? %> <%= render "cards/container/footer/saas/create", card: card %> <% else %> <%= render "cards/container/footer/create", card: card %> <% end %>
    ================================================ FILE: app/views/cards/drafts/show.html.erb ================================================ <% @page_title = @card.title %> <% @header_class = "header--card" %> <% content_for :header do %>
    <%= link_back_to_board(@card.board) %>
    <% end %>
    <%= render "cards/drafts/container", card: @card %> <%= render "layouts/lightbox" do %> <%= button_to_remove_card_image(@card) if @card.image.attached? %> <% end %>
    ================================================ FILE: app/views/cards/edit.html.erb ================================================ <%= turbo_frame_tag @card, :edit do %> <%# When entering edit mode, this turbo-stream updates the button area to show "Save changes" instead of "Edit". Turbo processes this stream as part of the frame response. %> <%= turbo_stream.update dom_id(@card, :card_closure_toggle) do %> <%= render "cards/container/save_button", card: @card %> <% end %> <%= form_with model: @card, id: dom_id(@card, :edit_form), data: { controller: "autoresize form local-save", local_save_key_value: "card-#{@card.id}", action: "turbo:submit-end->local-save#submit" } do |form| %>

    <%= 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 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, dir: "auto", maxlength: 255, 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 %>

    <%= form.rich_textarea :description, class: "card__description lexxy-content", placeholder: "Add some notes, context, pictures, or video about this…", data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %> <%= general_prompts(@card.board) %> <% end %> <%= link_to "Close editor and discard changes", @card, data: { form_target: "cancel", bridge__form_target: "cancel", bridge_title: "Cancel" }, hidden: true %> <% end %> <% end %> ================================================ FILE: app/views/cards/index.html.erb ================================================ <% @page_title = @user_filtering.selected_boards_label %> <% turbo_exempts_page_from_cache %> <%= render "cards/broadcasts", filter: @filter %> <% content_for :header do %>

    <%= @user_filtering.selected_boards_label %>

    <% if board = @filter.single_board %> <%= link_to_edit_board board %> <% end %>
    <% end %> <%= render "filters/settings", filter_url: cards_path, user_filtering: @user_filtering, no_filtering_url: cards_path %> <%= turbo_frame_tag :cards_container do %>
    <%= with_automatic_pagination :cards_paginated_container, @page do %> <%= render "cards/display/previews", cards: @page.records, draggable: true %> <% end %>
    No cards match this filter
    <% end %> ================================================ FILE: app/views/cards/index.json.jbuilder ================================================ json.array! @page.records, partial: "cards/card", as: :card ================================================ FILE: app/views/cards/not_nows/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals: { board: @card.board }) %> <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> ================================================ FILE: app/views/cards/pins/_pin_button.html.erb ================================================
    <% if card.pinned_by? Current.user %> <%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Unpin this card" } do %> <%= icon_tag "pinned" %> Unpin this card (shift+p) <% end %> <% else %> <%= button_to card_pin_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Pin this card" } do %> <%= icon_tag "unpinned" %> Pin this card (shift+p) <% end %> <% end %>
    ================================================ FILE: app/views/cards/pins/show.html.erb ================================================ <%= turbo_frame_tag @card, :pin do %> <%= render "cards/pins/pin_button", card: @card %> <% end %> ================================================ FILE: app/views/cards/previews/index.turbo_stream.erb ================================================ <%= turbo_stream.remove "#{params[:target]}-load-page-#{@page.number}" %> <%= turbo_stream.append params[:target] do %> <%= render partial: "cards/display/preview", collection: @page.records, as: :card, cached: true %> <% unless @page.last? %> <%= cards_next_page_link params[:target], page: @page, filter: @filter, fetch_on_visible: @filter.indexed_by.closed? %> <% end %> <% end %> ================================================ FILE: app/views/cards/readings/create.turbo_stream.erb ================================================ <%= turbo_stream.remove @notification if @notification %> ================================================ FILE: app/views/cards/show.html.erb ================================================ <% @page_title = @card.title %> <% @header_class = "header--card" %> <% content_for :head do %> <%= card_social_tags(@card) %> <% end %> <% content_for :header do %>
    <%= link_back_to_board @card.board, prefer_referrer: [ root_path, cards_path, board_path(@card.board) ] %>
    <% end %> <%= turbo_stream_from @card %> <%= turbo_stream_from @card, :activity %>
    <%= render "cards/container", card: @card %> <%= render "cards/messages", card: @card %> <%= render "layouts/lightbox" do %> <%= button_to_remove_card_image(@card) if @card.image.attached? %> <% end %>
    <%= bridged_share_url_button(bridge_share_card_description(@card)) %> ================================================ FILE: app/views/cards/show.json.jbuilder ================================================ json.partial! "cards/card", card: @card json.steps @card.steps, partial: "cards/steps/step", as: :step ================================================ FILE: app/views/cards/steps/_step.html.erb ================================================ <%= turbo_frame_tag step do %>
  • <%= form_with model: [step.card, step], data: { controller: "form" } do |form| %> <%= form.check_box :completed, { class: "step__checkbox", data: { action: "change->form#submit" } } %> <% end %> <%= link_to step.content, edit_card_step_path(step.card, step), class: "step__content" %>
  • <% end %> ================================================ FILE: app/views/cards/steps/_step.json.jbuilder ================================================ json.cache! step do json.(step, :id, :content, :completed) end ================================================ FILE: app/views/cards/steps/create.turbo_stream.erb ================================================ <%= turbo_stream.before dom_id(@card, :new_step) do %> <%= render "cards/steps/step", step: @step %> <% end %> ================================================ FILE: app/views/cards/steps/destroy.turbo_stream.erb ================================================ <%= turbo_stream.remove @step %> ================================================ FILE: app/views/cards/steps/edit.html.erb ================================================ <%= turbo_frame_tag @step do %>
    <%= form_with model: [@card, @step], class: "step", data: { controller: "form" } do |form| %> <%= form.check_box :completed, { class: "step__checkbox", checked: @step.completed?, disabled: true } %> <%= form.text_field :content, class: "input step__content step__content--edit hide-focus-ring", placeholder: "Name this step…", required: true, autofocus: true, autocomplete: "off", data: { action: "keydown.esc->form#cancel focus->form#select", "1p-ignore": "true" } %> <%= form.button type: "submit", class: "btn btn--positive txt-xx-small" do %> <%= icon_tag "check" %> Save changes <% end %> <%= link_to "Cancel changes", card_step_path(@card, @step), data: { form_target: "cancel" }, hidden: true %> <% end %> <%= button_to card_step_path(@card, @step), method: :delete, class: "btn btn--negative txt-xx-small" do %> <%= icon_tag "trash" %> Delete this step <% end %>
    <% end %> ================================================ FILE: app/views/cards/steps/index.json.jbuilder ================================================ json.array! @card.steps, partial: "cards/steps/step", as: :step ================================================ FILE: app/views/cards/steps/show.html.erb ================================================ <%= turbo_frame_tag @step do %> <%= render "cards/steps/step", step: @step %> <% end %> ================================================ FILE: app/views/cards/steps/show.json.jbuilder ================================================ json.partial! "cards/steps/step", step: @step ================================================ FILE: app/views/cards/steps/update.turbo_stream.erb ================================================ <%= turbo_stream.replace @step do %> <%= render "cards/steps/step", step: @step %> <% end %> <%= turbo_stream.replace dom_id(@card, "bubble") do %> <%= render "cards/display/preview/bubble", card: @card %> <% end %> ================================================ FILE: app/views/cards/taggings/_tag.html.erb ================================================ ================================================ FILE: app/views/cards/taggings/create.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @card, :tags ], partial: "cards/display/perma/tags", method: "morph", locals: { card: @card.reload }) %> ================================================ FILE: app/views/cards/taggings/new.html.erb ================================================ <%= turbo_frame_tag @card, :tagging do %>
    Tag this…t
    <%= form_with url: card_taggings_path(@card), id: dom_id(@card, :tags_form), data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit" }, class: "flex flex-column gap-half full-width margin-block-half" do |form| %> <%= form.text_field :tag_title, placeholder: @tags.any? ? "Add a new tag or filter…" : "Name this tag…", class: "input txt-small full-width", autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd" } %> <% end %>
    <% end %> ================================================ FILE: app/views/cards/triage/_columns.html.erb ================================================ <%= turbo_frame_tag card, :columns, src: edit_card_column_path(card), target: "_top", refresh: "morph" %> ================================================ FILE: app/views/cards/update.turbo_stream.erb ================================================ <% container_partial = @card.drafted? ? "cards/drafts/container" : "cards/container" %> <%= turbo_stream.replace dom_id(@card, :card_container), partial: container_partial, method: :morph, locals: { card: @card.reload } %> <%= turbo_stream.update dom_id(@card, :edit) do %> <%= render "cards/container/content_display", card: @card %> <% end %> <%= turbo_stream.replace dom_id(@card, :card_closure_toggle) do %> <%= render "cards/container/closure", card: @card %> <% end %> ================================================ FILE: app/views/cards/watches/_refresh.turbo_stream.erb ================================================ <%= turbo_stream.replace dom_id(card, :watch_button) do %> <%= render "cards/watches/watch_button", card: card %> <% end %> <%= turbo_stream.replace dom_id(card, :comment_watchers) do %> <%= render "cards/comments/watchers", card: card %> <% end %> ================================================ FILE: app/views/cards/watches/_watch_button.html.erb ================================================
    <% if card.watched_by? Current.user %> <%= button_to card_watch_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Stop watching" } do %> <%= icon_tag "bell" %> Stop watching (shift+n) <% end %> <% else %> <%= button_to card_watch_path(card), class: "btn btn--circle-mobile", data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Watch this" } do %> <%= icon_tag "bell-off" %> Watch this (shift+n) <% end %> <% end %>
    ================================================ FILE: app/views/cards/watches/create.turbo_stream.erb ================================================ <%= render "cards/watches/refresh", card: @card %> ================================================ FILE: app/views/cards/watches/destroy.turbo_stream.erb ================================================ <%= render "cards/watches/refresh", card: @card %> ================================================ FILE: app/views/cards/watches/show.html.erb ================================================ <%= turbo_frame_tag @card, :watch do %> <%= render "cards/watches/watch_button", card: @card %> <% end %> ================================================ FILE: app/views/client_configurations/android_v1.json ================================================ { "settings": {}, "rules": [ { "patterns": [ ".*" ], "properties": { "context": "default", "presentation": "default", "query_string_presentation": "replace", "uri": "hotwire://fragment/web", "fallback_uri": "hotwire://fragment/web", "pull_to_refresh_enabled": true } }, { "patterns": [ "/new$", "/new\\?.+$", "/edit$", "/edit\\?.+$", "/cards/[0-9]+/draft", "/notifications/settings", "/account/settings", "/account/join_code" ], "properties": { "context": "modal", "pull_to_refresh_enabled": false } }, { "patterns": [ "/devices$" ], "properties": { "context": "modal", "pull_to_refresh_enabled": false, "allow_untenanted_navigation": true } }, { "patterns": [ "/native/login/blank" ], "properties": { "uri": "hotwire://fragment/login/blank" } }, { "patterns": [ "/native/login/email" ], "properties": { "uri": "hotwire://fragment/login/email" } }, { "patterns": [ "/native/login/magic_code" ], "properties": { "uri": "hotwire://fragment/login/magic_code" } }, { "patterns": [ "/native/login/signup_completion" ], "properties": { "uri": "hotwire://fragment/login/signup_completion" } }, { "patterns": [ "/native/settings" ], "properties": { "uri": "hotwire://fragment/settings" } }, { "patterns": [ "/my/pins" ], "properties": { "uri": "hotwire://fragment/pins" } }, { "patterns": [ "/notifications$" ], "properties": { "uri": "hotwire://fragment/notifications" } } ] } ================================================ FILE: app/views/client_configurations/ios_v1.json ================================================ { "settings": { }, "rules": [ { "patterns": [ ".*" ], "properties": { "context": "default", "query_string_presentation": "replace", "pull_to_refresh_enabled": true } }, { "patterns": [ "/new$", "/new\\?.+$", "/edit$", "/edit\\?.+$", "/accounts$", "/cards/[0-9]+/draft" ], "properties": { "context": "modal", "pull_to_refresh_enabled": false } }, { "patterns": [ "/native/my_menu$" ], "properties": { "context": "modal", "view_controller": "main_menu" } }, { "patterns": [ "/native/add_account$" ], "properties": { "context": "modal", "view_controller": "login" } }, { "patterns": [ "/native/login$" ], "properties": { "view_controller": "login" } }, { "patterns": [ "/my/pins$" ], "properties": { "view_controller": "pinned" } }, { "patterns": [ "/notifications/tray$" ], "properties": { "view_controller": "notifications" } }, { "patterns": [ "internal/devtools/enable*" ], "properties": { "context": "modal", "view_controller": "dev_settings_launcher" } }, { "patterns": [ "/native/settings$" ], "properties": { "view_controller": "settings" } } ] } ================================================ FILE: app/views/columns/_column.json.jbuilder ================================================ json.cache! column do json.(column, :id, :name, :color) json.created_at column.created_at.utc end ================================================ FILE: app/views/columns/_refresh_adjacent_columns.turbo_stream.erb ================================================ <% column.adjacent_columns.each do |adjacent_column| %> <%= turbo_stream.replace(dom_id(adjacent_column), partial: "boards/show/column", method: :morph, locals: { column: adjacent_column }) %> <% end %> ================================================ FILE: app/views/columns/cards/drops/closures/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals:{ board: @card.board }) %> ================================================ FILE: app/views/columns/cards/drops/columns/create.turbo_stream.erb ================================================ <%= turbo_stream.replace(dom_id(@column), partial: "boards/show/column", method: :morph, locals: { column: @column }) %> ================================================ FILE: app/views/columns/cards/drops/not_nows/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals:{ board: @card.board }) %> ================================================ FILE: app/views/columns/cards/drops/streams/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals:{ board: @card.board, page: @page }) %> ================================================ FILE: app/views/columns/left_positions/create.turbo_stream.erb ================================================ <% if @left_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.before(@left_column, partial: "boards/show/column", locals: { column: @column }) %> <%= render "columns/refresh_adjacent_columns", column: @column %> <% end %> ================================================ FILE: app/views/columns/right_positions/create.turbo_stream.erb ================================================ <% if @right_column %> <%= turbo_stream.remove(dom_id(@column)) %> <%= turbo_stream.after(@right_column, partial: "boards/show/column", locals: { column: @column }) %> <%= render "columns/refresh_adjacent_columns", column: @column %> <% end %> ================================================ FILE: app/views/columns/show/_add_card_button.html.erb ================================================
    <%= button_to board_cards_path(board), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } }, data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add", class: "show-on-touch" %> Add a card C <% end %>
    <%= access_involvement_advance_button(board, Current.user, show_watchers: true) %>
    ================================================ FILE: app/views/entropy/_auto_close.html.erb ================================================ <% url = local_assigns[:url] %> <% disabled = local_assigns[:disabled] %>
    <%= form_with model: model, url: url, data: { controller: "form" } do |form| %> <%= render "entropy/knob", form: form, name: :auto_postpone_period_in_days, current_value: model.auto_postpone_period_in_days, knob_options: Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS, label: "Days until auto-close", disabled: disabled %> <% end %>
    ================================================ FILE: app/views/entropy/_knob.html.erb ================================================ <% current_index = knob_options.index(current_value) || knob_options.index(Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS) %>
    <% knob_options.each_with_index do |value, index| %> <% end %> <%= form.range_field :slider, class: "knob__slider", data: { action: "input->knob#sliderChanged change->form#submit", knob_target: "slider" }, "aria-hidden": true, max: knob_options.length - 1, min: 0, disabled: disabled %>
    Days
    ================================================ FILE: app/views/event_summaries/_event_summary.html.erb ================================================ <% unless event_summary.body.blank? %>
    <%= event_summary.body %>
    <% end %> ================================================ FILE: app/views/events/_day.html.erb ================================================
    <%= render "events/day_timeline/columns", day_timeline: day_timeline %> <%= render "events/empty_days", day_timeline: day_timeline %>
    ================================================ FILE: app/views/events/_empty_days.html.erb ================================================ <% earliest, latest = day_timeline.earliest_time, day_timeline.latest_time %> <% if earliest.present? %> <% if earliest == latest %>

    <%= local_datetime_tag earliest, style: :agoorweekday, class: "txt-ink txt-capitalize" %>

    No activity

    <% elsif earliest < latest %>

    <%= local_datetime_tag earliest, style: :agoorweekday, class: "txt-ink txt-capitalize" %> – <%= local_datetime_tag latest, style: :agoorweekday, class: "txt-ink txt-capitalize" %>

    No activity for <%= (latest - earliest).seconds.in_days.round + 1 %> days

    <% end %> <% else %>

    <%= day_timeline.has_activity? ? "No more activity" : "No activity" %>

    <% end %> ================================================ FILE: app/views/events/_event.html.erb ================================================ <% cache event do %> <%# Template Dependency Updated: _layout.html.erb 2026-01-26 %> <% if lookup_context.exists?("events/event/eventable/_#{event.action}") %> <%= render "events/event/eventable/#{event.action}", event: event %> <% else %> <%= render "events/event/eventable/#{event.eventable_type.demodulize.underscore}", event: event %> <% end %> <% end %> ================================================ FILE: app/views/events/day_timeline/_column.html.erb ================================================

    <%= column.title %> <% if column.events_by_hour.any? %> <%= link_to events_day_timeline_column_path(column, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> <%= icon_tag "grid", class: "translucent" %> Expand column <% end %> <% end %>

    <% column.events_by_hour.each do |hour, events| %> <%= events_at_hour_container(column, hour) do %> <%= render partial: "events/event", collection: events, cached: true %> <%= local_datetime_tag events.first.created_at, class: "event__timestamp txt-small translucent" %> <% end %> <% end %> <% if column.has_more_events? %> <% end %>
    ================================================ FILE: app/views/events/day_timeline/_columns.html.erb ================================================ <% cache [ day_timeline.events ] do %> <%# Template Dependency Updated: _layout.html.erb 2026-01-26 %> <% if day_timeline.has_activity? %>
    <%= render "events/day_timeline/column", column: day_timeline.added_column %> <%= render "events/day_timeline/column", column: day_timeline.updated_column %> <%= render "events/day_timeline/column", column: day_timeline.closed_column %>
    <% end %> <% end %> ================================================ FILE: app/views/events/day_timeline/columns/_events.html.erb ================================================
    <% column.events_by_hour.each do |hour, events| %> <% if events.any? %> <%= render partial: "events/event", collection: events, cached: true %> <% end %> <% end %>
    ================================================ FILE: app/views/events/day_timeline/columns/show.html.erb ================================================ <% @page_title = @column.base_title %> <% content_for :header do %>
    <%= back_link_to "Activity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @column.title %>

    <% end %> <%= render "events/day_timeline/columns/events", column: @column %> ================================================ FILE: app/views/events/days/index.html.erb ================================================ <%= day_timeline_pagination_frame_tag @day_timeline do %> <%= render "events/day", day_timeline: @day_timeline %> <%= day_timeline_pagination_link(@day_timeline, @filter) %> <% end %> ================================================ FILE: app/views/events/event/_attachments.html.erb ================================================ <% if eventable&.has_attachments? || eventable&.has_remote_images? || eventable&.has_remote_videos? %> <%= render partial: "events/event/attachments/attachment", collection: eventable.attachments %> <%= render partial: "events/event/attachments/remote_image", collection: eventable.remote_images %> <%= render partial: "events/event/attachments/remote_video", collection: eventable.remote_videos %> <% end %> ================================================ FILE: app/views/events/event/_layout.html.erb ================================================ <%= link_to event.notifiable_target, id: dom_id(event, "timelined"), class: "event event--#{ event.action } #{ "golden-effect" if event.card.golden? } center-block flex flex-column full-width align-start justify-start position-relative", style: "--card-color: #{ card.closed? ? "var(--color-card-complete)" : card.color };", data: { turbo_frame: "_top", related_element_target: "related", related_element_group_value: card.id, action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>

    Card number <%= card.number %> <%= event.board.name %>

    <% unless event.action.in?(%w[ card_closed card_published card_reopened ]) %> <%= icon_tag event_action_icon(event), class: "event__icon" %> <% end %>
    <%= avatar_image_tag(event.creator) %>
    <%= event.description_for(Current.user).to_html %> <%= yield %>
    <% if event.card.image.present? %>
    <%= image_tag event.card.image.presence || "", size: 120, aria: { hidden: true } %>
    <% end %> <% end %> ================================================ FILE: app/views/events/event/attachments/_attachment.html.erb ================================================ <% variant = Attachments::VARIANTS[:small] %> <% width = attachment.metadata["width"] %> <% height = attachment.metadata["height"] %> <% if attachment.previewable? %> <%= image_tag rails_representation_path(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %> <% elsif attachment.variable? %> <%= image_tag rails_representation_path(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %> <% else %>
    <%= attachment.filename.extension&.downcase.presence || "unknown" %>
    <% end %> ================================================ FILE: app/views/events/event/attachments/_remote_image.html.erb ================================================ <%= image_tag remote_image.url, skip_pipeline: true, class: "attachment attachment--image", width: remote_image.width, height: remote_image.height %> ================================================ FILE: app/views/events/event/attachments/_remote_video.html.erb ================================================ <%= tag.video controls: true, class: "attachment attachment--video", width: remote_video.width, height: remote_video.height do %> <%= tag.source src: remote_video.url, type: remote_video.content_type %> <% end %> ================================================ FILE: app/views/events/event/eventable/_card.html.erb ================================================ <%= render "events/event/layout", card: event.eventable, event: event %> ================================================ FILE: app/views/events/event/eventable/_card_published.html.erb ================================================ <%= render "events/event/layout", card: event.eventable, event: event do %> <%= format_excerpt(event&.eventable.description, length: 200) -%> <%= render "events/event/attachments", eventable: event.eventable %> <% end %> ================================================ FILE: app/views/events/event/eventable/_comment.html.erb ================================================ <%= render "events/event/layout", card: event.eventable.card, event: event do %> <%= format_excerpt(event&.eventable.body, length: 200) -%> <%= render "events/event/attachments", eventable: event.eventable %> <% end %> ================================================ FILE: app/views/events/index/_add_board_button.html.erb ================================================
    <%= link_to new_board_path, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_display_title: true, bridge_icon_url: bridge_icon("board"), bridge_slot: "left" } do %> <%= icon_tag "board" %> Add a board B <% end %>
    ================================================ FILE: app/views/events/index/_add_card_button.html.erb ================================================
    <% if board = user_filtering.single_board_or_first %> <%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %> <%= icon_tag "add" %> Add a card C <% end %> <% end %>
    ================================================ FILE: app/views/events/index/_filter.html.erb ================================================ <%= form_with url: events_path, method: :get, class: "display-inline position-relative", data: { controller: "form", turbo_frame: "cards_container", turbo_action: "advance" } do |form| %> <% unless user_filtering.boards.one? %> <%= render "events/index/filter/board", user_filtering: %> <% end %> <% unless user_filtering.users.one? %> by <%= render "events/index/filter/user", user_filtering: %> <% end %> <% end %> ================================================ FILE: app/views/events/index/filter/_board.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "flex-inline flex-wrap position-relative quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside turbo:morph@window->multi-selection-combobox#refresh dialog:close@document->filter#clearInput", filter_show: user_filtering.show_boards?, multi_selection_combobox_no_selection_label_value: user_filtering.selected_boards_label } do %> <%= filter_dialog "Board…" do %> <%= filter_title "Board…" %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small font-weight-normal", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> ================================================ FILE: app/views/events/index/filter/_user.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "flex-inline flex-wrap position-relative quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside turbo:morph@window->multi-selection-combobox#refresh dialog:close@document->filter#clearInput", filter_show: user_filtering.show_creators?, multi_selection_combobox_no_selection_label_value: "everyone" } do %> <%= filter_dialog "Person…" do %> <%= filter_title "Person…" %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small font-weight-normal", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> ================================================ FILE: app/views/events/index.html.erb ================================================ <% @page_title = "Home" %> <% @header_class = "header--events" %> <%= render "cards/broadcasts", filter: @filter %> <% content_for :header do %> <%= render "events/index/add_card_button", user_filtering: @user_filtering %>

    <% if @user_filtering.boards.many? %> Activity <%= @user_filtering.filter.boards.any? ? "in" : "across" %> <% else %> Latest Activity <% end %> <%= render "events/index/filter", user_filtering: @user_filtering %>

    <%= render "events/index/add_board_button", user_filtering: @user_filtering %> <% end %> <%= tag.div id: "activity", class: "events", data: { controller: "pagination", pagination_paginate_on_intersection_value: true } do %> <%= day_timeline_pagination_frame_tag @day_timeline do %> <%= render "events/day", day_timeline: @day_timeline %> <%= day_timeline_pagination_link(@day_timeline, @filter) %> <% end %> <% end %> ================================================ FILE: app/views/filters/_filter_toggle.html.erb ================================================
    <% if filter.persisted? %> <%= button_to filter_path(filter), method: :delete, class: "btn txt-x-small btn--reversed", data: { controller: "tooltip" }, form_class: "inline" do %> <%= icon_tag "bookmark-outline" %> Delete custom view <% end %> <% else %> <%= button_to filters_path(filter.as_params), method: :post, class: "btn txt-x-small", data: { controller: "tooltip" }, form_class: "inline" do %> <%= icon_tag "bookmark-outline" %> Save custom view <% end %> <% end %>
    ================================================ FILE: app/views/filters/_settings.html.erb ================================================ <%= tag.details class: "expandable-on-native", open: true, data: { controller: "expandable-on-native", expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %> <%= tag.summary "Toggle filters", class: "btn btn--plain margin-block-end", data: { bridge__buttons_target: "button", bridge_title: "Toggle filters", bridge_icon_url: bridge_icon("funnel") } %> <%= tag.aside \ class: class_names("filters margin-block-end", { "filters--expanded": user_filtering.expanded? }), data: { controller: "toggle-enable toggle-class filter-settings dialog-manager", toggle_class_toggle_class: "filters--expanded", filter_settings_filters_set_class: "filters--has-filters-set", filter_settings_no_filtering_url_value: no_filtering_url, filter_settings_refresh_url_value: settings_refresh_path, filter_settings_cards_url_value: cards_path, turbo_permanent: true } do %> <%= form_with url: filter_url, method: :get, class: "display-contents", data: { controller: "form", turbo_frame: "cards_container", filter_settings_target: "form", action: "turbo:submit-end->filter-settings#resetIfNoFiltering", turbo_action: "advance" } do |form| %> <%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: "element" } %> <%= yield form if block_given? %> <%= render "filters/settings/terms", filter: user_filtering.filter, form: form do %> <%= yield form if block_given? %> <% end %> <%= render "filters/settings/controls", user_filtering: user_filtering, form: form %> <%= render "filters/settings/toggle", user_filtering: user_filtering %> <% end %> <%= render "filters/settings/manage", user_filtering: user_filtering, no_filtering_url: no_filtering_url %> <% end %> <% end %> ================================================ FILE: app/views/filters/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("filter-settings-save-toggle", partial: "filters/filter_toggle", locals: { filter: @filter }) %> ================================================ FILE: app/views/filters/destroy.turbo_stream.erb ================================================ <%= turbo_stream.replace("filter-settings-save-toggle", partial: "filters/filter_toggle", locals: { filter: @filter }) %> ================================================ FILE: app/views/filters/settings/_assignees.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_assignees?, multi_selection_combobox_no_selection_label_value: "Assigned to…", multi_selection_combobox_label_prefix_value: "Assigned to" } do %> <%= filter_dialog "Assigned to…" do %> <%= filter_title "Assigned to…" %> <% if Current.account.users.active.many? %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_boards.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close->filter#clearInput", filter_show: user_filtering.show_boards?, multi_selection_combobox_no_selection_label_value: "Board…", multi_selection_combobox_label_prefix_value: "" } do %> <%= filter_dialog "Board…" do %> <%= filter_title "Board…" %> <% if user_filtering.boards.many? %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_cards.html.erb ================================================ <% if filter.card_ids.present? %> <%= filter_chip_tag "Cards #{filter.card_ids.join(", ")}", filter.as_params.without(:card_ids) %> <% end %> ================================================ FILE: app/views/filters/settings/_closers.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_closers?, multi_selection_combobox_no_selection_label_value: "Closed by…", multi_selection_combobox_label_prefix_value: "Closed by" } do %> <%= filter_dialog "Closed by…" do %> <%= filter_title "Closed by…" %> <% if user_filtering.users.many? %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_controls.html.erb ================================================ <%= render "filters/settings/boards", user_filtering: user_filtering %> <%= render "filters/settings/sorted_by", user_filtering: user_filtering %> <%= render "filters/settings/indexed_by", user_filtering: user_filtering %> <%= render "filters/settings/tags", user_filtering: user_filtering %> <%= render "filters/settings/assignees", user_filtering: user_filtering %> <%= render "filters/settings/creators", user_filtering: user_filtering %> <%= render "filters/settings/closers", user_filtering: user_filtering %> ================================================ FILE: app/views/filters/settings/_creators.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_creators?, multi_selection_combobox_no_selection_label_value: "Added by…", multi_selection_combobox_label_prefix_value: "Added by" } do %> <%= filter_dialog "Added by…" do %> <%= filter_title "Added by…" %> <% if user_filtering.users.many? %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_indexed_by.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: class_names("quick-filter"), data: { controller: "dialog filter combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_indexed_by?, combobox_default_value_value: "all", combobox_default_label_value: "Status…", combobox_with_default_class: "quick-filter--with-default" } do %> <%= filter_dialog "Filter by…" do %> Filter by status… <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_manage.html.erb ================================================ <% filter = user_filtering.filter %> <% clear_url = filter.single_board ? board_path(filter.single_board) : no_filtering_url %>
    <%= link_to clear_url, class: "btn btn--remove txt-x-small", data: { controller: "hotkey tooltip", action: "keydown.esc@document->hotkey#click"} do %> <%= icon_tag "close" %> Clear all <% end %>
    ================================================ FILE: app/views/filters/settings/_sorted_by.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: class_names("quick-filter"), data: { controller: "dialog filter combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_sorted_by?, combobox_default_value_value: "latest", combobox_with_default_class: "quick-filter--with-default" } do %> <%= filter_dialog "Sort by…" do %> Sort by… <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_tags.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog filter multi-selection-combobox", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside dialog:close@document->filter#clearInput", filter_show: user_filtering.show_tags?, multi_selection_combobox_no_selection_label_value: "Tagged…" } do %> <%= filter_dialog "Tagged…" do %> <%= filter_title "Tagged…" %> <% if user_filtering.tags.many? %> <%= text_field_tag nil, nil, id: nil, placeholder: "Filter…", class: "input input--transparent txt-small", autofocus: true, type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_terms.html.erb ================================================ <%= form.search_field "terms[]", placeholder: "Filter these cards… [F]", class: "filter__terms input txt-x-small", autofocus: false, autocomplete: :off, autocorrect: "off", data: { "1p-ignore": "true", controller: "hotkey touch-placeholder", filter_settings_target: "field", touch_placeholder_placeholder_value: "Filter these cards…", action: "keydown.f@document->hotkey#focus input->filter-settings#resetIfNoFiltering input->form#debouncedSubmit keydown.enter->form#submitToTopTarget blur->form#submitToTopTarget" } %> <% if filter.terms.present? %> <% filter.terms.each do |term| %> <%= filter_chip_tag %Q("#{term}"), filter.as_params_without(:terms, term) %> <%= hidden_field_tag "terms[]", term, data: { filter_settings_target: "field" } %> <% end %> <%= yield form if block_given? %> <% end %> ================================================ FILE: app/views/filters/settings/_time_window.html.erb ================================================ <% filter = user_filtering.filter %> <%= tag.div class: "quick-filter", data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside", filter_show: filter.public_send("#{name}_window").present? } do %> <%= label %>… <%= form_with url: cards_path, method: :get, class: "popup__list", data: { controller: "form" } do |form| %> <% filter.as_params.except(name).each do |key, value| %> <%= filter_hidden_field_tag key, value %> <% end %> <% TimeWindowParser::VALUES.each do |value| %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/filters/settings/_toggle.html.erb ================================================ <% filter = user_filtering.filter %> <% if user_filtering.expanded? %> <% else %> <% end %> ================================================ FILE: app/views/filters/settings_refreshes/create.turbo_stream.erb ================================================ <%= turbo_stream.replace("filter-settings-save-toggle", partial: "filters/filter_toggle", locals: { filter: @filter }) %> ================================================ FILE: app/views/join_codes/inactive.html.erb ================================================ <% @page_title = "That code is all used up" %>

    <%= @page_title %>

    Ask someone from <%= @join_code.account.name %> to send you a new link or increase the limit.

    <%= link_to "OK", "https://www.fizzy.do", class: "btn btn--link" %>

    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/join_codes/new.html.erb ================================================ <% @page_title = "Join #{@join_code.account.name} in Fizzy" %>

    <%= @page_title %>

    <%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %>
    <% end %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/layouts/_lightbox.html.erb ================================================ <%= tag.dialog class:"lightbox", aria: { label: "Image Viewer (Press escape to close)" }, data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop transitionend->lightbox#handleTransitionEnd" } do %> <% end %> ================================================ FILE: app/views/layouts/_theme_preference.html.erb ================================================ <%= javascript_tag nonce: true do %> const theme = localStorage.getItem("theme") if (theme && theme !== "auto") { document.documentElement.dataset.theme = theme } <% end %> ================================================ FILE: app/views/layouts/action_text/contents/_content.html.erb ================================================
    <%= format_html yield -%>
    ================================================ FILE: app/views/layouts/application.html.erb ================================================ <%= render "layouts/shared/head" %>
    <%= render "layouts/shared/flash" %> <%= render "layouts/shared/time_zone" if Current.user %>
    <%= yield %>
    <%= yield :footer %> <% if Current.user && !@hide_footer_frames %> <% end %> <%= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %>
    ================================================ FILE: app/views/layouts/mailer.html.erb ================================================ <%= yield %>
    ================================================ FILE: app/views/layouts/mailer.text.erb ================================================ <%= yield %> ================================================ FILE: app/views/layouts/public.html.erb ================================================ <%= render "layouts/shared/head" %>
    <%= render "layouts/shared/flash" %>
    <%= yield %>
    <%= yield :footer %>
    ================================================ FILE: app/views/layouts/shared/_colophon.html.erb ================================================ <%= link_to "https://www.fizzy.do", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %> <%= icon_tag "fizzy", class: "v-align-middle" %> Fizzy™ <% end %> is designed, built, and backed by <%= link_to "https://37signals.com", class: "txt-current font-weight-bold txt-nowrap", target: "_blank", rel: "noopener noreferrer" do %> <%= icon_tag "37signals", class: "v-align-middle" %> 37signals™ <% end %> ================================================ FILE: app/views/layouts/shared/_flash.html.erb ================================================ <%= turbo_frame_tag :flash do %> <% if notice = flash[:notice] || flash[:alert] %>
    <%= notice %>
    <% end %> <% end %> ================================================ FILE: app/views/layouts/shared/_head.html.erb ================================================ <%= page_title_tag %> <% unless @disable_view_transition %> <% end %> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %> <%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %> <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> <%= tenanted_action_cable_meta_tag %> <%= render "layouts/shared/user_css" %> <%= yield :head %> ================================================ FILE: app/views/layouts/shared/_time_zone.html.erb ================================================ <% if timezone_from_cookie.present? && timezone_from_cookie != Current.user.timezone %> <%= auto_submit_form_with url: my_timezone_path, method: :put do %> <%= hidden_field_tag :timezone_name, timezone_from_cookie.name %> <% end %> <% end %> ================================================ FILE: app/views/layouts/shared/_user_css.html.erb ================================================ <% if Current.user %> <% end %> ================================================ FILE: app/views/layouts/shared/_welcome_letter.html.erb ================================================
    <%= image_tag "jf-avatar.jpg", size: 36%>

    Welcome, and thanks for signing up for Fizzy.

    To get you started, we set you up with a Fizzy board called Playground. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll see the Playground when you close this message.

    If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, we’re all here for you.

    Thanks again and all the best,

    Jason Fried, jason@37signals.com
    CEO & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY

    ================================================ FILE: app/views/mailers/account_mailer/cancellation.html.erb ================================================

    Your Fizzy account <%= @account.name %> was cancelled.

    What happens now?

    • No one can access the account anymore
    • <% if @account.try(:subscription) %>
    • We won't charge you anymore
    • <% end %>
    • Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>
    Changed your mind?

    If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.

    ================================================ FILE: app/views/mailers/account_mailer/cancellation.text.erb ================================================ Your Fizzy account "<%= @account.name %>" was cancelled. WHAT HAPPENS NOW? - No one can access the account anymore <% if @account.try(:subscription) %> - We won't charge you anymore <% end %> - Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %> CHANGED YOUR MIND? If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible. ================================================ FILE: app/views/mailers/export_mailer/completed.html.erb ================================================

    Download your Fizzy data

    Your Fizzy data export has finished processing and is ready to download.

    <%= link_to "Download your data", export_download_url(@export) %>

    ================================================ FILE: app/views/mailers/export_mailer/completed.text.erb ================================================ Your Fizzy data export has finished processing and is ready to download. Download your data: <%= export_download_url(@export) %> ================================================ FILE: app/views/mailers/identity_mailer/email_change_confirmation.text.erb ================================================ Confirm your email address change <%= "=" * 80 %> Hit the link below to use this email address in Fizzy: <%= user_email_address_confirmation_url(user_id: @user.id, email_address_token: @token) %> If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button. ================================================ FILE: app/views/mailers/import_mailer/completed.html.erb ================================================

    Your import of <%= @account.name %> is complete!

    <%= link_to "Go to your account", landing_url(script_name: @account.slug) %>

    ================================================ FILE: app/views/mailers/import_mailer/completed.text.erb ================================================ Your import of <%= @account.name %> is complete! Here's the URL: <%= landing_url(script_name: @account.slug) %> ================================================ FILE: app/views/mailers/import_mailer/failed.html.erb ================================================

    Unfortunately, we couldn't import your Fizzy account.

    <% if @import.failed_due_to_conflict? %>

    It looks like the account you are trying to import already exists.

    <% elsif @import.failed_due_to_invalid_export? %>

    The ZIP file isn't a Fizzy account export.

    <% else %>

    This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists.

    <% end %> ================================================ FILE: app/views/mailers/import_mailer/failed.text.erb ================================================ Unfortunately, we couldn't import your Fizzy account. <% if @import.failed_due_to_conflict? -%> It looks like the account you are trying to import already exists. <% elsif @import.failed_due_to_invalid_export? -%> The ZIP file isn't a Fizzy account export. <% else -%> This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export, or reach out for help if the problem persists. <% end -%> Need help? Send us an email at support@fizzy.do ================================================ FILE: app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb ================================================ <% if @magic_link.for_sign_in? %>

    Fizzy verification code

    Please enter this 6-character verification code on the Fizzy sign-in page:

    <% else %>

    Welcome to Fizzy!

    Please enter this 6-character verification code to finish creating your account:

    <% end %> <%= @magic_link.code %>

    This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    <% if account = @magic_link.identity.accounts.last %>

    P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %>

    <% end %> ================================================ FILE: app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb ================================================ <% if @magic_link.for_sign_in? %> Please enter this 6-character verification code on the Fizzy sign-in page: <% else %> Please enter this 6-character verification code to finish creating your account: <% end %> <%= @magic_link.code %> This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>. <% if account = @magic_link.identity.accounts.last %> P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %> <% end %> ================================================ FILE: app/views/mailers/notification/bundle_mailer/_notification.html.erb ================================================
    <%= mail_avatar_tag(notification.creator) %> <%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %>
    ================================================ FILE: app/views/mailers/notification/bundle_mailer/_notification.text.erb ================================================ <%= render "notification/bundle_mailer/#{notification.source_type.underscore}/body", notification: notification %> ================================================ FILE: app/views/mailers/notification/bundle_mailer/event/_body.html.erb ================================================ <% event = notification.source %>

    <%= notification.creator.familiar_name %>

    <%= event_notification_body(event) %>

    ================================================ FILE: app/views/mailers/notification/bundle_mailer/event/_body.text.erb ================================================ <% event = notification.source %> <%= notification.creator.name %>: <%= event_notification_body(event).squish %> <%= url_for notification %> ================================================ FILE: app/views/mailers/notification/bundle_mailer/mention/_body.html.erb ================================================ <% mention = notification.source %> <%= "#{mention.mentioner.first_name} mentioned you:" %>

    <%= mention.source.mentionable_content.truncate(250) %>

    ================================================ FILE: app/views/mailers/notification/bundle_mailer/mention/_body.text.erb ================================================ <% mention = notification.source %> <%= mention.mentioner.first_name %> mentioned you: <%= mention.source.mentionable_content.truncate(250) %> <%= url_for mention %> ================================================ FILE: app/views/mailers/notification/bundle_mailer/notification.html.erb ================================================

    Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>

    You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.

    <% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %>

    <%= link_to board.name, board %>

    <% board_notifications.group_by(&:card).each do |card, notifications| %> <%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %> <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> <% end %> <% end %> ================================================ FILE: app/views/mailers/notification/bundle_mailer/notification.text.erb ================================================ Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %> You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>. <% @notifications.group_by { |n| n.card.board }.sort_by { |board, _| board.name.downcase }.each do |board, board_notifications| %> <%= board.name %> <%= "-" * board.name.length %> <% board_notifications.group_by(&:card).each do |card, notifications| %> <%= "##{ card.number } #{ card.title }" %> <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> <% end %> <% end %> -------------------------------------------------------------------------------- Fizzy emails you about new notifications every few hours. Change how often you get these: <%= notifications_settings_url %> Unsubscribe from all email notifications: <%= new_notifications_unsubscribe_url(access_token: @unsubscribe_token) %> ================================================ FILE: app/views/my/_menu.html.erb ================================================ ================================================ FILE: app/views/my/access_tokens/_access_token.html.erb ================================================ <%= access_token.description %> <%= access_token.permission.humanize %> <%= local_datetime_tag access_token.created_at, style: :datetime %> <%= button_to my_access_token_path(access_token), method: :delete, class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent", data: { turbo_confirm: "Are you sure you want to permanently revoke this access token?" } do %> <%= icon_tag "trash" %> Edit this token <% end %> ================================================ FILE: app/views/my/access_tokens/_access_token.json.jbuilder ================================================ json.(access_token, :id, :description, :permission) json.created_at access_token.created_at.utc ================================================ FILE: app/views/my/access_tokens/index.html.erb ================================================ <% @page_title = "Personal access tokens" %> <% content_for :header do %>
    <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <% if @access_tokens.any? %>

    Tokens you have generated that can be used to access the Fizzy API.

    <%= render partial: "my/access_tokens/access_token", collection: @access_tokens %>
    Description Permission Created
    <% else %>

    Personal access tokens can be used like a password to access the Fizzy developer API. You can have as many tokens as you need and revoke access to each one at any time.

    <% end %> <%= link_to new_my_access_token_path, class: "btn btn--link" do %> <%= icon_tag "add" %> Generate a new access token <% end %>
    ================================================ FILE: app/views/my/access_tokens/index.json.jbuilder ================================================ json.array! @access_tokens, partial: "my/access_tokens/access_token", as: :access_token ================================================ FILE: app/views/my/access_tokens/new.html.erb ================================================ <% @page_title = "Generate a personal access token" %> <% content_for :header do %>
    <%= back_link_to "tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= form_with model: @access_token, url: my_access_tokens_path, scope: :access_token, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
    <%= form.label :description, "Access token description" %> <%= form.text_field :description, required: true, autofocus: true, class: "input", placeholder: "e.g. Github", data: { action: "keydown.esc@document->form#cancel" } %>
    <%= form.label :permission %> <%= form.select :permission, options_for_select({ "Read" => "read", "Read + Write" => "write"}), {}, class: "input input--select" %>
    <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> Generate access token <% end %> <%= link_to "Cancel and go back", my_access_tokens_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
    ================================================ FILE: app/views/my/access_tokens/show.html.erb ================================================ <% @page_title = "New personal access token" %> <% content_for :header do %>
    <%= back_link_to "Tokens", my_access_tokens_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>

    Be sure to save this access token now because you won’t be able to see it again.

    <%= tag.button class: "btn btn--link center", data: { controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: @access_token.token } do %> <%= icon_tag "copy-paste" %> Copy access token <% end %>
    ================================================ FILE: app/views/my/identities/_account.json.jbuilder ================================================ json.cache! account do json.(account, :id, :name, :slug) json.created_at account.created_at.utc end ================================================ FILE: app/views/my/identities/show.json.jbuilder ================================================ json.id @identity.id json.accounts @identity.users_with_active_accounts do |user| json.partial! "my/identities/account", account: user.account json.user user, partial: "users/user", as: :user end ================================================ FILE: app/views/my/menus/_accounts.html.erb ================================================ <% if accounts.many? %> <% cache [ Current.identity, accounts, Current.account ] do %> <%= collapsible_nav_section "Accounts" do %> <%# Bust cache 1 Dec 2025 %> <% accounts.each do |account| %> <%= filter_place_menu_item landing_path(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %> <% end %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/_boards.html.erb ================================================ <%= collapsible_nav_section "Boards" do %> <% boards.each do |board| %> <%= my_menu_board_item(board) %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/_custom_views.html.erb ================================================ <%= collapsible_nav_section "Custom views", id: "my-filters" do %> <%= form_with url: cards_path, method: :get, data: { controller: "form" } do |form| %> <% filters.each do |filter| %> <%= my_menu_filter_item(filter) %> <% end %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/_jump.html.erb ================================================
    <%= jump_field_tag %>
    ================================================ FILE: app/views/my/menus/_people.html.erb ================================================ <%= collapsible_nav_section "People" do %> <% users.each do |user| %> <%= my_menu_user_item(user) %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/_settings.html.erb ================================================ <%= 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_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %> Sign out <% end %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/_shortcuts.html.erb ================================================ <%= collapsible_nav_section "Shortcuts", class: "nav__section popup__section nav__section--secret" do %> <%= filter_place_menu_item cards_path(indexed_by: :golden), "Golden cards", "filter" %> <%= filter_place_menu_item cards_path(indexed_by: :stalled), "Stalled cards", "filter" %> <%= filter_place_menu_item cards_path(indexed_by: :postponing_soon), "Cards closing soon", "filter" %> <%= filter_place_menu_item cards_path(creation: "today"), "Added today", "filter" %> <%= filter_place_menu_item cards_path(closure: "today"), "Done today", "filter" %> <% end %> ================================================ FILE: app/views/my/menus/_tags.html.erb ================================================ <%= collapsible_nav_section "Tags" do %> <% tags.each do |tag| %> <%= my_menu_tag_item(tag) %> <% end %> <% end %> ================================================ FILE: app/views/my/menus/show.html.erb ================================================ <%= turbo_frame_tag "my_menu", target: "_top" do %> <%= render "my/menus/jump" do %> <%= render "my/menus/boards", boards: @boards %> <%= render "my/menus/tags", tags: @tags %> <%= render "my/menus/people", users: @users %> <%= render "my/menus/settings" %> <%= render "my/menus/shortcuts" %> <%= render "my/menus/accounts", accounts: @accounts %> <% end %>
    <%= render "layouts/shared/colophon" %>
    <% end %> ================================================ FILE: app/views/my/passkeys/_passkey.html.erb ================================================
  • <%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %> <% if icon = passkey.authenticator&.icon %> <%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %> <%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %> <% else %> <%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %> <%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %> <% end %> <%= passkey.name.presence || "Passkey" %> <% end %>
  • ================================================ FILE: app/views/my/passkeys/edit.html.erb ================================================ <% @page_title = "Edit Passkey" %> <% content_for :header do %>
    <%= back_link_to "Passkeys", my_passkeys_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <% if params[:created] %>

    Your passkey has been registered. Give it a name so you can identify it later.

    <% end %> <%= form_with model: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
    <%= form.label "Name your passkey" %> <%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
    <%= form.submit "Save", class: "btn btn--link center" %> <% end %>
    <%= button_to my_passkey_path(@passkey), method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %> <%= icon_tag "trash" %> Remove this passkey <% end %>
    ================================================ FILE: app/views/my/passkeys/index.html.erb ================================================ <% @page_title = "Passkeys" %> <% content_for :head do %> <%= passkey_creation_options_meta_tag(@creation_options) %> <% end %> <% content_for :header do %>
    <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>

    Passkeys let you sign in securely without a password or email code.

    <% if @passkeys.any? %>
      <%= render partial: "my/passkeys/passkey", collection: @passkeys %>
    <% end %>
    <%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %> <%= icon_tag "add" %> Register a passkey <% end %>

    Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key

    Something went wrong while registering your passkey.

    Passkey registration was cancelled. Try again when you are ready.

    ================================================ FILE: app/views/my/pins/_pin.html.erb ================================================
    <%= render "cards/display/preview", card: pin.card %> <%= button_to card_pin_path(pin.card), method: :delete, class: "tray__remove-pin-btn btn btn--circle borderless" do %> <%= icon_tag "pinned" %> Unpin this card <% end %>
    ================================================ FILE: app/views/my/pins/_tray.html.erb ================================================ <%= turbo_stream_from Current.user, :pins_tray %>
    <%= 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, data: { controller: "frame", action: "turbo:morph@document->frame#reload" } %> <% end %>
    ================================================ FILE: app/views/my/pins/index.html.erb ================================================ <% @page_title = "Pinned" %> <% content_for :header do %>
    <%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag "pins" do %> <%= render partial: "my/pins/pin", collection: @pins %> <% end %>
    ================================================ FILE: app/views/my/pins/index.json.jbuilder ================================================ json.array! @pins do |pin| json.partial! "cards/card", card: pin.card end ================================================ FILE: app/views/notifications/_notification.html.erb ================================================ <% cache notification do %> <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> <%= notification_tag notification do %> <%= render "notifications/notification/header", notification: notification do %> <%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %> <% end %> <%= render "notifications/notification/body", notification: notification %> <% end %> <% end %> ================================================ FILE: app/views/notifications/_notification.json.jbuilder ================================================ json.cache! notification do json.(notification, :id, :unread_count) json.read notification.read? json.read_at notification.read_at&.utc json.created_at notification.created_at.utc json.source_type notification.source_type.underscore json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification json.creator notification.creator, partial: "users/user", as: :user json.card do json.(notification.card, :id, :number, :title, :status) json.board_name notification.card.board.name json.closed notification.card.closed? json.postponed notification.card.postponed? json.url card_url(notification.card) json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column end json.url notification_url(notification) end ================================================ FILE: app/views/notifications/_tray.html.erb ================================================ <%= turbo_stream_from Current.user, :notifications %>
    <%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: { controller: "frame-reloader", action: "focus@window->frame-reloader#reload" } %>
    <%= link_to notifications_settings_path, class: "btn borderless tray__notification-settings", title: "Notification Settings", data: { action: "dialog#close" } do %> <%= icon_tag "settings" %> Settings <% end %>
    <%= link_to notifications_path, class: "btn borderless flex-item-grow position-relative overflow-ellipsis", data: { action: "click->dialog#close" } do %> <%= icon_tag "bell" %> See more new items See older items <% end %>
    <%= button_to bulk_reading_path(from_tray: true), class: "btn borderless tray__clear-notifications", title: "Mark all notifications as read", data: { action: "dialog#close badge#clear", turbo_frame: "notifications" }, form: { class: "full-width", data: { navigable_list_target: "item" } } do %> <%= icon_tag "check" %> Clear all <% end %>
    ================================================ FILE: app/views/notifications/index/_read_notifications.html.erb ================================================ <% if page.records.any? %>

    Previously seen

    <%= render partial: "notifications/notification", collection: page.records, cached: true %>
    <% end %> ================================================ FILE: app/views/notifications/index/_unread_notifications.html.erb ================================================
    <% if unread.any? %>

    New for you

    <%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
    <% else %>
    Nothing new for you
    <% end %>
    <%= render partial: "notifications/notification", collection: unread, cached: true %>
    <% if unread.any? %> <% total_unread_count = Current.user.notifications.unread.count %> <% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
    Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
    <% end %> <% end %>
    ================================================ FILE: app/views/notifications/index.html.erb ================================================ <% @page_title = "Notifications" %> <% @hide_footer_frames = true %> <% content_for :header do %>
    <%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <%= link_to notifications_settings_path, class: "btn btn--circle-mobile", data: { bridge__overflow_menu_target: "item", bridge_title: "Notification settings" } do %> <%= icon_tag "settings" %> Notification settings <% end %>
    <% end %>
    <%= render "notifications/index/unread_notifications", unread: @unread if @unread %> <%= render "notifications/index/read_notifications", page: @page %>
    <%= notifications_next_page_link(@page) if @page.records.any? %> ================================================ FILE: app/views/notifications/index.json.jbuilder ================================================ json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true ================================================ FILE: app/views/notifications/index.turbo_stream.erb ================================================ <%= turbo_stream.append :notifications_list_read, partial: "notifications/notification", collection: @page.records %> <%= turbo_stream.replace :next_page, notifications_next_page_link(@page) %> ================================================ FILE: app/views/notifications/notification/_body.html.erb ================================================
    <%= avatar_image_tag notification.creator %>

    <%= render "notifications/notification/#{notification.source_type.underscore}/body", notification: notification %>

    ================================================ FILE: app/views/notifications/notification/_header.html.erb ================================================
    Card number <%= notification.card.number %> <%= notification.card.board.name %>
    <%= notification.creator.familiar_name %>
    <%= local_datetime_tag(notification.created_at, style: :timeordate) %>
    <%= yield %>
    ================================================ FILE: app/views/notifications/notification/event/_body.html.erb ================================================ <% event = notification.source %>
    <%= event_notification_title(event) %>
    <%= event_notification_body(event) %>
    ================================================ FILE: app/views/notifications/notification/event/_body.json.jbuilder ================================================ json.title event_notification_title(notification.source) json.body event_notification_body(notification.source) ================================================ FILE: app/views/notifications/notification/mention/_body.html.erb ================================================ <% mention = notification.source %> <%= mention.mentioner.first_name %> @mentioned you
    <%= mention.source.mentionable_content.truncate(200) %>
    ================================================ FILE: app/views/notifications/notification/mention/_body.json.jbuilder ================================================ mention = notification.source json.title "#{mention.mentioner.first_name} @mentioned you" json.body mention.source.mentionable_content.truncate(200) ================================================ FILE: app/views/notifications/readings/create.turbo_stream.erb ================================================ <%= turbo_stream.remove @notification %> <%= turbo_stream.prepend :notifications_list_read, partial: "notifications/notification", locals: { notification: @notification } %> ================================================ FILE: app/views/notifications/readings/destroy.turbo_stream.erb ================================================ <%= turbo_stream.remove @notification %> <%= turbo_stream.prepend :notifications_list, partial: "notifications/notification", locals: { notification: @notification } %> ================================================ FILE: app/views/notifications/settings/_board.html.erb ================================================ <%= turbo_frame_tag board, :involvement do %>

    <%= board.name %>

    <% end %> ================================================ FILE: app/views/notifications/settings/_browser.html.erb ================================================ <% unless (platform.safari? || platform.chrome?) && platform.ios? %>
    <% case when platform.firefox? && platform.android? %>

    Turn on notifications for <%= platform.browser.capitalize %>.

    1. Tap <%= icon_tag "lock", alt: "the View site information button" %> in the address bar.
    2. Tap Notification to change to Allowed.
    <% when platform.edge? && platform.desktop? %>

    Turn on notifications for this website.

    1. Click <%= icon_tag "lock", alt: "the View site information button" %> left of the address bar.
    2. Under Permissions for this site > Notifications, choose Allow.

    Turn on notifications for <%= platform.browser.capitalize %>.

      <% if platform.windows? %>
    1. Click Start, then Settings.
    2. Go to System > Notification.
    3. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
    4. <% else %>
    5. Click in the top left.
    6. Click System Settings…
    7. Click Notifications.
    8. Click <%= platform.browser.capitalize %>.
    9. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
    10. <% end %>
    <% when platform.firefox? && platform.desktop? %>

    Turn on notifications for this website.

    1. Click <%= platform.browser.capitalize %> in the top left.
    2. Click Settings…
    3. Click Privacy & Security in the sidebar.
    4. Scroll down to Permissions.
    5. Click Settings next to Notifications.
    6. Select Allow next to <%= root_url %>.

    Turn on notifications for <%= platform.browser.capitalize %>.

      <% if platform.windows? %>
    1. Click Start, then Settings.
    2. Go to System > Notification.
    3. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for <%= platform.browser.capitalize %>.
    4. <% else %>
    5. Click in the top left.
    6. Click System Settings…
    7. Click Notifications.
    8. Click <%= platform.browser.capitalize %>.
    9. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
    10. <% end %>
    <% when platform.chrome? && platform.desktop? %>

    Turn on notifications for this website.

    1. Click the <%= icon_tag "sliders", alt: "View site information" %> icon in the address bar.
    2. Click Site Settings.
    3. Ensure notifications are Allowed.

    Turn on notifications for <%= platform.browser.capitalize %>.

      <% if platform.windows? %>
    1. Click Start, then Settings.
    2. Go to System > Notification.
    3. Click <%= icon_tag "switch", alt: "the switch" %> ON for <%= platform.browser.capitalize %>.
    4. <% else %>
    5. Click in the top left.
    6. Click System Settings…
    7. Click Notifications.
    8. Click <%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %>.
    9. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.
    10. <% end %>
    <% when platform.chrome? && platform.android? %>

    Turn on notifications for <%= platform.browser.capitalize %>.

    1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
    2. Tap Settings.
    3. Tap Notifications.
    4. Tap <%= icon_tag "switch", alt: "the switch" %> to Allow <%= platform.browser.capitalize %> notifications.
    5. Tap <%= icon_tag "switch", alt: "the switch" %> next to Web apps.
    6. Tap <%= icon_tag "bell-alert", alt: "the notification bell" %> and select Allow.
    <% when platform.safari? && platform.desktop? %>

    Turn on notifications for this website.

    1. Click in the top left.
    2. Click System Settings…
    3. Click Notifications.
    4. Click <%= request.base_url %> in the list.
    5. Click <%= icon_tag "switch", alt: "the switch" %> to Allow notifications.

    Turn on notifications for <%= platform.browser.capitalize %>.

    1. Click <%= platform.browser.capitalize %> in the top left.
    2. Click Settings…
    3. Click the Websites tab.
    4. Click Notifications in the sidebar.
    5. Click <%= request.base_url %> in the list.
    6. Select Allow.
    <% else %>

    Ensure notifications are enabled for <%= root_url %> in your web browser settings.

    <% end %>
    <% end %> ================================================ FILE: app/views/notifications/settings/_email.html.erb ================================================

    Email Notifications

    Get a single email with all your notifications every few hours, daily, or weekly.
    <%= form_with model: settings, url: notifications_settings_path, method: :patch, local: true, data: { controller: "form" } do |form| %>
    <%= form.label :bundle_email_frequency, "Email me about new notifications..." %>
    <%= form.select :bundle_email_frequency, bundle_email_frequency_options_for(settings), {}, class: "input input--select txt-align-center", data: { action: "change->form#submit" } %> <% end %>
    ================================================ FILE: app/views/notifications/settings/_install.html.erb ================================================ <% unless (platform.chrome? && !platform.ios?) || (platform.firefox? && !platform.android?) %>

    <%= platform.safari? && platform.desktop? ? "…or install" : "Install " -%> Fizzy as a web app.

    <% case when platform.edge? %>
    1. Click <%= icon_tag "install-edge", alt: "the app available - install Fizzy button" %>in the address bar.
    2. Click Install.
    <% when platform.chrome? && platform.android? %>
    1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
    2. Tap Install app in the menu.
    <% when platform.firefox? && platform.android? %>
    1. Tap the <%= icon_tag "menu-dots-vertical", alt: "More options" %> menu button.
    2. Tap Install in the menu.
    <% when platform.safari? && platform.desktop? %>
    1. Click File in the top left.
    2. Click Add to Dock…
    <% when (platform.safari? || platform.chrome?) && platform.ios? %>

    To receive push notifications in <%= platform.browser.capitalize %> for <%= platform.operating_system %>, you must first install Fizzy as a web app.

    1. Tap <%= icon_tag "share", alt: "the share button" %>
    2. Tap Add to Home Screen.
    <% else %>

    Some platforms require you to install Fizzy as a web app to receive push notifications.

    <% end %>
    <% end %> ================================================ FILE: app/views/notifications/settings/_push_notifications.html.erb ================================================

    Push notifications are ON OFF

    <%= icon_tag "lifebuoy" %> Help me fix this Not receiving notifications?

    When push notifications aren’t working, this can usually be fixed by checking your notification settings to make sure they’re allowed.

    <%= render partial: "notifications/settings/browser" %> <%= render partial: "notifications/settings/system" %> <%= render partial: "notifications/settings/install" %>
    ================================================ FILE: app/views/notifications/settings/_system.html.erb ================================================

    Check your <%= platform.operating_system %> settings

    <% case when platform.firefox? && platform.android? %>
    1. Tap the <%= icon_tag "menu-dots-vertical.svg", alt: "More options" %> menu button.
    2. Tap Settings.
    3. Tap Notifications.
    4. Tap <%= icon_tag "switch", alt: "the toggle button" %> to Allow <%= platform.browser.capitalize %> notifications.
    <% when platform.edge? && platform.desktop? %>
    1. Click Start, then Settings.
    2. Go to System > Notification.
    3. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
    <% when (platform.firefox? || platform.chrome?) && platform.desktop? %>
      <% if platform.windows? %>
    1. Click Start, then Settings.
    2. Go to System > Notification.
    3. Click <%= icon_tag "switch", alt: "the toggle button" %> ON for Fizzy.
    4. <% else %>
    5. Click in the top left.
    6. Click System Settings…
    7. Click Notifications.
    8. Click Fizzy.
    9. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
    10. <% end %>
    <% when platform.safari? && platform.desktop? %>
    1. Click in the top left.
    2. Click System Settings…
    3. Click Notifications.
    4. Click Fizzy.
    5. Click <%= icon_tag "switch", alt: "the allow notifications switch" %> to Allow notifications.
    <% when (platform.safari? || platform.chrome?) && platform.ios? %>
    1. Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
    2. Scroll to and tap Fizzy.
    3. Tap Notifications.
    4. Tap <%= icon_tag "switch", alt: "the allow notifications switch button" %> to Allow Notifications.
    <% when platform.chrome? && platform.android? %>
    1. Open the <%= icon_tag "gear", aria: { hidden: "true" } %> Settings app.
    2. Tap Notifications.
    3. Tap App notifications.
    4. Scroll to Fizzy.
    5. Tap <%= icon_tag "switch", alt: "the switch" %> to Allow Notifications.
    <% else %>

    Ensure notifications are allowed for <%= platform.browser.capitalize %> in your system settings.

    <% end %>
    ================================================ FILE: app/views/notifications/settings/show.html.erb ================================================ <% @page_title = "Notification Settings" %> <% content_for :header do %>

    <%= @page_title %>

    <% end %>

    Boards

    <%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
    <%= render "notifications/settings/push_notifications" %> <%= render "notifications/settings/native_devices" if Fizzy.saas? %> <%= render "notifications/settings/email", settings: @settings %>
    ================================================ FILE: app/views/notifications/settings/show.json.jbuilder ================================================ json.bundle_email_frequency Current.user.settings.bundle_email_frequency ================================================ FILE: app/views/notifications/trays/show.html.erb ================================================ <%= turbo_frame_tag "notifications" do %> <%= render partial: "notifications/notification", collection: @notifications, cached: true %> <% end %> ================================================ FILE: app/views/notifications/trays/show.json.jbuilder ================================================ json.array! @notifications, partial: "notifications/notification", as: :notification ================================================ FILE: app/views/notifications/unsubscribes/new.html.erb ================================================

    Unsubscribing from all email notifications as <%= @user.name %>…

    <%= auto_submit_form_with model: @user, url: notifications_unsubscribe_path(access_token: params[:access_token]), method: :post do |form| %> <%= form.submit "Unsubscribe now", class: "btn" %> <% end %>
    ================================================ FILE: app/views/notifications/unsubscribes/show.html.erb ================================================

    You’re unsubscribed

    Thanks, <%= @user.first_name %>! Fizzy won’t send you any more email notifications. If you change your mind, you can turn them back on in <%= link_to "Notification Settings", notifications_settings_path %>.

    <%= link_to root_path, class: "btn btn--link margin-block-start" do %> <%= icon_tag "arrow-left" %> Back to Fizzy <% end %>
    ================================================ FILE: app/views/prompts/boards/users/_user.html.erb ================================================ " sgid="<%= user.attachable_sgid %>"> ================================================ FILE: app/views/prompts/boards/users/index.html.erb ================================================ <%= render partial: "prompts/boards/users/user", collection: @users %> ================================================ FILE: app/views/prompts/cards/_card.html.erb ================================================ ================================================ FILE: app/views/prompts/cards/index.html.erb ================================================ <%= render partial: "prompts/cards/card", collection: @cards %> ================================================ FILE: app/views/prompts/commands/_command.html.erb ================================================ <% command, description, editor_version = command %> ================================================ FILE: app/views/prompts/commands/index.html.erb ================================================ <%= render partial: "prompts/commands/command", collection: @commands %> ================================================ FILE: app/views/prompts/tags/_tag.html.erb ================================================ ================================================ FILE: app/views/prompts/tags/index.html.erb ================================================ <%= render partial: "prompts/tags/tag", collection: @tags %> ================================================ FILE: app/views/prompts/users/index.html.erb ================================================ <%= render partial: "prompts/boards/users/user", collection: @users %> ================================================ FILE: app/views/public/_footer.html.erb ================================================
    <%= render "layouts/shared/colophon" %>
    ================================================ FILE: app/views/public/boards/card_previews/index.turbo_stream.erb ================================================ <%= turbo_stream.remove "#{params[:target]}-load-page-#{@page.number}" %> <%= turbo_stream.append params[:target] do %> <%= render partial: "cards/display/public_preview", board: @page.records, as: :card, locals: { draggable: true }, cached: true %> <% unless @page.last? %> <%= public_board_cards_next_page_link @board, params[:target], page: @page, fetch_on_visible: params[:target] == "closed-cards" %> <% end %> <% end %> ================================================ FILE: app/views/public/boards/columns/closeds/show.html.erb ================================================ <% @page_title = "Column: Done" %> <% content_for :header do %>
    <%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :closed_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :closed_column, @page do %> <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %>
    No cards here
    <% end %>
    <% end %>
    <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/public/boards/columns/not_nows/show.html.erb ================================================ <% @page_title = "Column: Not now" %> <% content_for :header do %>
    <%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :not_now_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :not_now_column, @page do %> <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %>
    No cards here
    <% end %>
    <% end %>
    <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/public/boards/columns/show.html.erb ================================================ <% @page_title = "Column: #{ @column.name }" %> <% content_for :header do %>
    <%= back_link_to @column.board.name, published_board_url(@column.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag @column, :cards do %>
    <% if @page.used? %> <%= with_automatic_pagination dom_id(@column, :cards), @page do %> <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %>
    No cards here
    <% end %>
    <% end %>
    <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/public/boards/columns/streams/show.html.erb ================================================ <% @page_title = "Column: Maybe?" %> <% content_for :header do %>
    <%= back_link_to @board.name, published_board_url(@board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= turbo_frame_tag :stream_column do %>
    <% if @page.used? %> <%= with_automatic_pagination :stream_column, @page do %> <%= render "cards/display/public_previews", cards: @page.records %> <% end %> <% else %>
    No cards here
    <% end %>
    <% end %>
    <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/public/boards/show/_closed.html.erb ================================================ ================================================ FILE: app/views/public/boards/show/_column.html.erb ================================================ ================================================ FILE: app/views/public/boards/show/_columns.html.erb ================================================ <%= turbo_frame_tag :cards_container do %>
    <%= render "public/boards/show/not_now", board: board %>
    <%= render "public/boards/show/stream", board: board, page: page %>
    <%= render partial: "public/boards/show/column", collection: board.columns, cached: true %> <%= render "public/boards/show/closed", board: board %>
    <% end %> ================================================ FILE: app/views/public/boards/show/_not_now.html.erb ================================================ ================================================ FILE: app/views/public/boards/show/_stream.html.erb ================================================
    <%# render "boards/show/expander", title: "Maybe?", count: column.cards.active.count, column_id: dom_id(column) %> <%= render "boards/show/expander", title: "Maybe?", count: 2, column_id: "maybe" %> <%= link_to public_board_columns_stream_url(board.publication.key), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> <%= icon_tag "grid", class: "translucent" %> Maximize column <% end %>
    <%= column_frame_tag :stream_column, src: public_board_columns_stream_path(board.publication.key) %>
    ================================================ FILE: app/views/public/boards/show.html.erb ================================================ <% @page_title = @board.name %> <% @body_class = "contained-scrolling" %> <% content_for :head do %> <%= tag.meta property: "og:title", content: "#{@board.name} | #{Current.account.name}" %> <%= tag.meta property: "og:description", content: format_excerpt(@board&.public_description, length: 200) %> <%= tag.meta property: "og:image", content: "#{request.base_url}/opengraph.png" %> <%= tag.meta property: "og:url", content: published_board_url(@board) %> <%= tag.meta property: "twitter:title", content: "#{@board.name} | #{Current.account.name}" %> <%= tag.meta property: "twitter:description", content: format_excerpt(@board&.public_description, length: 200) %> <%= tag.meta property: "twitter:image", content: "#{request.base_url}/opengraph.png" %> <%= tag.meta property: "twitter:card", content: "summary_large_image" %> <% end %> <% content_for :header do %>

    <%= @page_title %>

    <% end %> <% if @board.public_description.present? %>
    <%= @board.public_description %>
    <% end %> <%= render "public/boards/show/columns", page: @page, board: @board %> <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/public/cards/show/_content.html.erb ================================================

    <%= tag.span card_html_title(card), class: "card__title-link" %>

    <%= card.description %>
    ================================================ FILE: app/views/public/cards/show/_steps.html.erb ================================================
      <% card.steps.each do |step| %>
    1. <%= check_box_tag :completed, { class: "step__checkbox", disabled: true, checked: step.completed? } %> <%= tag.span step.content, class: "step__content" %>
    2. <% end %>
    ================================================ FILE: app/views/public/cards/show.html.erb ================================================ <% @page_title = @card.title %> <% content_for :head do %> <%= tag.meta property: "og:title", content: "#{@card.title} | #{@card.board.name}" %> <%= tag.meta property: "og:description", content: format_excerpt(@card&.description, length: 200) %> <%= tag.meta property: "og:image", content: @card.image.attached? ? "#{request.base_url}#{url_for(@card.image)}" : "#{request.base_url}/app-icon.png" %> <%= tag.meta property: "og:url", content: published_card_url(@card) %> <%= tag.meta property: "twitter:title", content: "#{@card.title} | #{@card.board.name}" %> <%= tag.meta property: "twitter:description", content: format_excerpt(@card&.description, length: 200) %> <%= tag.meta property: "twitter:image", content: @card.image.attached? ? "#{request.base_url}#{url_for(@card.image)}" : "#{request.base_url}/app-icon.png" %> <%= tag.meta property: "twitter:card", content: "summary_large_image" %> <% end %> <% content_for :header do %>
    <%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>
    <%= card_article_tag @card, class: "card" do %>
    <%= render "cards/display/preview/board", card: @card %> <%= render "cards/display/preview/tags", card: @card %>
    <%= render "public/cards/show/content", card: @card %> <%= render "cards/display/public_preview/columns", card: @card if @card.open? %> <%= render "cards/display/common/stamp", card: @card %>
    <%= render "public/cards/show/steps", card: @card %>
    <%= render "cards/display/public_preview/meta", card: @card %> <%= render "cards/display/perma/background", card: @card %>
    <% end %>
    <%= render "layouts/lightbox" %>
    <% content_for :footer do %> <%= render "public/footer" %> <% end %> ================================================ FILE: app/views/pwa/manifest.json.erb ================================================ { "name": <%= [ "Fizzy", Rails.env.production? ? nil : Rails.env ].compact.join(" - ").to_json.html_safe %>, "icons": [ { "src": "/app-icon-192.png", "type": "image/png", "sizes": "192x192" }, { "src": "/app-icon.png", "type": "image/png", "sizes": "512x512" }, { "src": "/app-icon-192-maskable.png", "type": "image/png", "sizes": "192x192", "purpose": "maskable" } ], "start_url": "/", "display": "standalone", "scope": "/", "description": "Card-up the biggest issues in your projects", "categories": ["bugs", "business", "productivity"], "shortcuts": [ { "name": "Notifications", "description": "Catch up on recent notifications", "url": "<%= notifications_path %>", "icons": [{ "src": "<%= image_url("bell.svg") %>", "sizes": "any" }] }, { "name": "Latest Activity", "description": "See what’s new", "url": "<%= root_path %>", "icons": [{ "src": "<%= image_url("activity.svg") %>", "sizes": "any" }] } ] } ================================================ FILE: app/views/pwa/service_worker.js.erb ================================================ importScripts("<%= javascript_url("turbo-offline-umd.min") %>") // Documents - top-level navigation requests only // We need `cache: "no-cache"` here to work around // an annoying Safari PWA bug that predates offline mode TurboOffline.addRule({ match: (request) => request.destination === "document", except: /\/(edit|pin|watch|new)$/, handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24 * 3, networkTimeout: 3, fetchOptions: { cache: "no-cache" }, maxEntrySize: 1024 * 1024 }) }) TurboOffline.addRule({ match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/, handler: TurboOffline.handlers.cacheFirst({ cacheName: "assets", maxAge: 60 * 60 * 24 * 7, maxEntrySize: 1024 * 1024 }) }) TurboOffline.addRule({ match: /\/(boards|cards|users)\//, except: /\/(edit|pin|watch|new)$/, handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24 * 3, networkTimeout: 2, maxEntrySize: 1024 * 1024 }) }) TurboOffline.addRule({ match: /\/rails\/active_storage\//, handler: TurboOffline.handlers.networkFirst({ cacheName: "storage", maxAge: 60 * 60 * 24 * 7, networkTimeout: 2, maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs maxEntries: 500, fetchOptions: { mode: "cors" } }) }) // Everything else TurboOffline.addRule({ except: /\/(service-worker\.js|edit|pin|watch|new)$/, handler: TurboOffline.handlers.networkFirst({ cacheName: "main", maxAge: 60 * 60 * 24, networkTimeout: 3, maxEntrySize: 1024 * 1024 }) }) self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()) }) TurboOffline.start() self.addEventListener("push", (event) => { const data = event.data.json() event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) }) async function showNotification({ title, options }) { return self.registration.showNotification(title, options) } async function updateBadgeCount({ data: { badge } }) { return self.navigator.setAppBadge?.(badge || 0) } self.addEventListener("notificationclick", (event) => { event.notification.close() const url = new URL(event.notification.data.url, self.location.origin).href event.waitUntil(openURL(url)) }) async function openURL(url) { const clients = await self.clients.matchAll({ type: "window" }) const focused = clients.find((client) => client.focused) if (focused) { await focused.navigate(url) } else { await self.clients.openWindow(url) } } ================================================ FILE: app/views/reactions/_menu.html.erb ================================================
    <% EmojiHelper::REACTIONS.each do |character, title| %> <%= tag.button character, title: title, class: "reaction__emoji-btn btn btn--circle borderless hide-focus-ring", type: "button", data: { action: "reaction-emoji#insertEmoji dialog#close", emoji: character } %> <% end %>
    ================================================ FILE: app/views/reactions/_reaction.html.erb ================================================
    <%= avatar_tag reaction.reacter, aria: { label: "#{reaction.reacter.name} reacted #{reaction.content}" } %>
    <%= tag.span reaction.content, role: "button", class: [ "txt-small", { "txt-medium": reaction.all_emoji? } ], data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %> <%= button_to polymorphic_path([ *reaction_path_prefix_for(reaction.reactable), reaction ]), method: :delete, class: "reaction__delete btn btn--negative flex-item-justify-end", data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %> <%= icon_tag "trash" %> Delete this reaction <% end %>
    Press enter to delete this reaction ================================================ FILE: app/views/reactions/_reaction.json.jbuilder ================================================ json.cache! reaction do json.(reaction, :id, :content) json.reacter reaction.reacter, partial: "users/user", as: :user json.url polymorphic_url([ *reaction_path_prefix_for(reaction.reactable), reaction ]) end ================================================ FILE: app/views/reactions/_reactions.html.erb ================================================ <%= turbo_frame_tag reactable, :reacting do %>
    <%= render partial: "reactions/reaction", collection: reactable.reactions %>
    <%= turbo_frame_tag reactable, :new_reaction do %> <%= link_to new_polymorphic_path([ *reaction_path_prefix_for(reactable), :reaction ]), role: "button", class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open", data: { turbo_frame: dom_id(reactable, :new_reaction), action: "dialog#close" } do %> <%= image_tag "boost-color.svg", aria: { hidden: true } %> Add your own reaction <% end %> <% end %>
    <% end %> ================================================ FILE: app/views/reactions/create.turbo_stream.erb ================================================ <%= turbo_stream.replace([ @reactable, :reacting ]) do %> <%= render "reactions/reactions", reactable: @reactable.reload %> <% end %> ================================================ FILE: app/views/reactions/destroy.turbo_stream.erb ================================================ <%= turbo_stream.remove @reaction %> ================================================ FILE: app/views/reactions/index.html.erb ================================================ <%= render "reactions/reactions", reactable: @reactable %> ================================================ FILE: app/views/reactions/index.json.jbuilder ================================================ json.array! @reactable.reactions.ordered, partial: "reactions/reaction", as: :reaction ================================================ FILE: app/views/reactions/new.html.erb ================================================ <%= turbo_frame_tag @reactable, :new_reaction do %> <%= form_with model: [ *reaction_path_prefix_for(@reactable), Reaction.new ], class: "reaction reaction__form expanded", html: { aria: { label: "New reaction" } }, data: { controller: "form reaction-emoji", turbo_frame: dom_id(@reactable, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %> <%= render "reactions/menu" %> <%= form.button class: "reaction__submit-btn btn btn--circle borderless", type: "submit", data: { form_target: "submit" } do %> <%= icon_tag "check-circle" %> Submit <% end %> <%= link_to polymorphic_path([ *reaction_path_prefix_for(@reactable), :reactions ]), role: "button", data: { turbo_frame: dom_id(@reactable, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %> <%= icon_tag "close-circle" %> Cancel <% end %> <% end %> <% end %> ================================================ FILE: app/views/reactions/show.json.jbuilder ================================================ json.partial! "reactions/reaction", reaction: @reaction ================================================ FILE: app/views/searches/_form.html.erb ================================================ <%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { controller: "search-form", action: "search-form:reset->bar#reset", bar_target: "form", turbo_action: defined?(turbo_action) ? turbo_action : nil, turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %> <%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %> <%= text_field_tag :q, query_terms, class: "search__input input", type: "search", placeholder: "Find something…", autocomplete: "off", autofocus: true, data: { search_form_target: "searchInput", bar_target: "searchInput", action: "keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset" } %> <% end %> ================================================ FILE: app/views/searches/_result.html.erb ================================================
  • <%= link_to result.source, class: "search__result", data: { turbo_frame: "_top", action: "bar#reset" } do %>

    # <%= result.card.number %> <%= result.card_title %>

    <% if result.comment.present? %>
    <%= avatar_preview_tag result.card.creator %>
    <%= result.comment_body %>
    <% elsif result.card_id.present? %>
    <%= result.card_description %>
    <% end %>
    <%= result.card.board.name %> · <%= local_datetime_tag(result.created_at, style: :timeordate) %>
    <% end %>
  • ================================================ FILE: app/views/searches/_results.html.erb ================================================ ================================================ FILE: app/views/searches/show.html.erb ================================================ <% @page_title = @query ? "Search results for \"#{@query}\"" : "Search" %> <% content_for :header do %>
    <%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= render "form", query_terms: @query, turbo_action: "advance", target_turbo_frame: "bar_content" %> <%= turbo_frame_tag "bar_content" do %> <% if @card %> <%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %> <% else %> <%= render "results", page: @page, query: @query %> <% end %> <% end %>
    ================================================ FILE: app/views/searches/show.json.jbuilder ================================================ json.array! @page.records, partial: "cards/card", as: :card ================================================ FILE: app/views/sessions/_footer.html.erb ================================================
    <%= render "layouts/shared/colophon" %>. Need help? <%= mail_to "support@fizzy.do", "Send us an email", class: "txt-link" %>.
    ================================================ FILE: app/views/sessions/magic_links/show.html.erb ================================================ <% @page_title = "Check your email" %>
    ">

    <%= @page_title %>

    Then enter the verification code included in the email below:

    <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %> <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %> <% end %>

    The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

    <% if Rails.env.development? && flash[:magic_link_code].present? %>
    Psst, here's your code: <%= flash[:magic_link_code] %> DEV
    <% end %> <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/sessions/menus/show.html.erb ================================================ <% @page_title = "Choose an account" %> <% cache [ Current.identity, @accounts ] do %>
    <% if @accounts.any? %>

    Your Fizzy accounts

    <% @accounts.each do |account| %> <% end %> <% else %>

    Hmm...

    You don’t have any Fizzy accounts.

    <% end %> <%= link_to new_signup_path, class: "btn center txt-small margin-block-start", data: { turbo_prefetch: false } do %> Sign up for a new Fizzy account <% end %>
    <% end %> <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/sessions/new.html.erb ================================================ <% @page_title = "Enter your email" %> <% content_for :head do %> <%= passkey_request_options_meta_tag(@request_options) %> <% end %>

    Get into Fizzy

    <%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
    <% if Account.accepting_signups? %>

    New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we'll get you signed in.

    <% else %>

    Enter your email and we'll get you signed in.

    <% end %> <% end %> <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/sessions/starts/new.html.erb ================================================ <% @hide_footer_frames = true %> <% @page_title = "Signing in..." %>

    <%= @page_title %>

    Just a sec while we sign you in with <%= Current.account.name %>.

    <%= form_with url: session_start_path, method: :post, data: { controller: "form auto-submit" } do |form| %> <%= form.button "Sign in", type: "submit", class: "btn btn-primary", data: { form_target: "submit", turbo_submits_with: "Signing in..." } %> <% end %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/sessions/transfers/show.html.erb ================================================ <%= auto_submit_form_with method: :put %> ================================================ FILE: app/views/signups/completions/new.html.erb ================================================ <% @page_title = "Complete your sign-up" %>
    ">

    <%= @page_title %>

    <%= form_with model: @signup, url: signup_completion_path, scope: "signup", class: "flex flex-column gap", data: { controller: "form" } do |form| %> <%= form.text_field :full_name, class: "input txt-large", autocomplete: "name", placeholder: "Enter your full name…", autofocus: true, required: true, maxlength: 240 %>

    You're one step away. Just enter your name to get your own Fizzy account.

    <% if @signup.errors.any? %>

    Your changes couldn't be saved:

      <% @signup.errors.full_messages.each do |message| %>
    • <%= message %>
    • <% end %>
    <% end %> <% end %>

    <%= link_to new_account_import_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %> Or import a Fizzy account <% end %>

    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/signups/new.html.erb ================================================ <% @page_title = "Signup for Fizzy" %>

    Sign up

    <%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>

    Enter your email to create an account.

    <% end %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/tags/_tag.json.jbuilder ================================================ json.cache! tag do json.(tag, :id, :title) json.created_at tag.created_at.utc json.url cards_url(tag_ids: [ tag ]) end ================================================ FILE: app/views/tags/index.html.erb ================================================

    All tags

      <% @tags.each do |tag| %>
    • <%= tag.title %> <%= tag.cards.pluck(:title).to_sentence %> <%= button_to tag_path(tag), class: "btn txt-small", method: :delete, data: { turbo_confirm: "Are you sure?" } do %> <%= icon_tag "remove" %> Delete <% end %>
    • <% end %>
    ================================================ FILE: app/views/tags/index.json.jbuilder ================================================ json.array! @page.records, partial: "tags/tag", as: :tag ================================================ FILE: app/views/user_mailer/email_change_confirmation.html.erb ================================================

    Confirm your email address change

    <%= link_to "Yes use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>

    If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.

    ================================================ FILE: app/views/users/_access_tokens.html.erb ================================================

    Developer

    Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
    ================================================ FILE: app/views/users/_activity_timeline.html.erb ================================================
    <%= day_timeline_pagination_frame_tag day_timeline do %> <%= render "events/day", day_timeline: day_timeline %> <% if day_timeline.next_day %> <%= link_to "Load more…", user_path(user, day: day_timeline.next_day.strftime("%Y-%m-%d"), **filter.as_params), class: "day-timeline-pagination-link", data: { frame: day_timeline_pagination_frame_id_for(day_timeline.next_day), pagination_target: "paginationLink" } %> <% end %> <% end %>
    ================================================ FILE: app/views/users/_attachable.html.erb ================================================ <%= avatar_image_tag user %> <%= user.first_name %> ================================================ FILE: app/views/users/_data_export.html.erb ================================================

    Export your data

    Download an archive of your Fizzy data.

    Export your data

    This will generate a ZIP archive of all cards you have access to.

    We’ll email you a link to download the file when it’s ready. The link will expire after 24 hours.

    <%= button_to "Start export", user_data_exports_path(@user), method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
    ================================================ FILE: app/views/users/_theme.html.erb ================================================

    Appearance

    ================================================ FILE: app/views/users/_transfer.html.erb ================================================
    <% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>

    Devices

    Link to automatically log in on another device.

    <%= tag.button class: "btn", data: { action: "dialog#open", controller: "tooltip" } do %> <%= icon_tag "qr-code" %> Display auto-login QR code <% end %>

    Scan this code to instantly log in on another device:

    <%= qr_code_image(url) %>
    <%= button_to_copy_to_clipboard(url) do %> <%= icon_tag "copy-paste" %> Copy auto-login link <% end %>
    ================================================ FILE: app/views/users/_user.json.jbuilder ================================================ json.cache! user do json.(user, :id, :name, :role, :active) json.email_address user.identity&.email_address json.created_at user.created_at.utc json.url user_url(user) json.avatar_url user_avatar_url(user) end ================================================ FILE: app/views/users/avatars/show.svg.erb ================================================ ================================================ FILE: app/views/users/data_exports/show.html.erb ================================================ <% if @export.present? %> <% @page_title = "Download Export" %> <% else %> <% @page_title = "Download Expired" %> <% end %> <% content_for :header do %>
    <%= back_link_to @user.name, user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>

    <%= @page_title %>

    <% if @export.present? %>
    Your export is ready. The download should start automatically.
    <%= link_to "Download your data", rails_blob_path(@export.file, disposition: "attachment"), id: "download-link", class: "btn btn--link", data: { turbo: false, controller: "auto-click" } %> <% else %>
    That download link has expired. You'll need to <%= link_to "request a new export", user_path(@user), class: "txt-link" %>.
    <% end %>
    ================================================ FILE: app/views/users/edit.html.erb ================================================ <% @page_title = "Edit your profile" %> <% content_for :header do %>
    <%= back_link_to "profile", user_path(@user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>
    <%= form_with model: @user, method: :patch, class: "flex flex-column gap", data: { controller: "form upload-preview" } do |form| %>
    <% if @user.avatar.attached? %> <%= tag.button type: :submit, form: "avatar-delete-form", class: "btn btn--negative txt-small", data: { turbo_confirm: "Are you sure you want to remove your avatar? This can't be undone." } do %> <%= icon_tag "minus" %> Delete avatar <% end %> <% end %>
    <%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true, value: @user.identity.email_address %> <%= link_to "Change email", new_user_email_address_path(user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
    <% if @user.errors.any? %>

    Your changes couldn't be saved:

      <% @user.errors.full_messages.each do |message| %>
    • <%= message %>
    • <% end %>
    <% end %> <%= link_to "Cancel and go back", user_path(@user), data: { form_target: "cancel", turbo_frame: "_top" }, hidden: true %> <% end %> <%= form_with url: user_avatar_url(@user), method: :delete, id: "avatar-delete-form" %>
    ================================================ FILE: app/views/users/email_addresses/confirmations/invalid_token.html.erb ================================================ <% @page_title = "Link expired" %>

    <%= @page_title %>

    That email confirmation link is no longer valid—they expire after 30 minutes. You’ll have to try again.

    <%= link_to "Change my email address", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: "btn btn--link center" %>

    If you get stuck, <%= mail_to "support@fizzy.do", "send us an email" %> and we’ll get you back on track.

    ================================================ FILE: app/views/users/email_addresses/confirmations/show.html.erb ================================================ <% @page_title = "Confirm email change" %>

    <%= @page_title %>

    Just a sec while we confirm your new email address.

    <%= form_with url: user_email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %> <%= form.hidden_field :email_address_token, value: params[:email_address_token] %> <% end %>
    ================================================ FILE: app/views/users/email_addresses/create.html.erb ================================================ <% @page_title = "Confirm your new email address" %> <% content_for :header do %>
    <%= back_link_to "My profile", edit_user_path(@user, script_name: @user.account.slug), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>

    Check your email

    We just sent an email to <%= params[:email_address] %>

    Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.

    <%= link_to "Done", edit_user_path(@user, script_name: @user.account.slug), class: "btn btn--link center" %>
    ================================================ FILE: app/views/users/email_addresses/new.html.erb ================================================ <% @page_title = "Change your email" %> <% content_for :header do %>
    <%= back_link_to "My profile", edit_user_path(@user, script_name: @user.account.slug), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>

    <%= @page_title %>

    <%= form_with url: user_email_addresses_path(user_id: @user.id), method: :post, class: "flex flex-column gap-half", data: { controller: "form", turbo: false } do |form| %>

    Enter your new email address, then check your email to confirm the change.

    <% end %>
    ================================================ FILE: app/views/users/events/show.html.erb ================================================

    <%= "What #{Current.user == @user ? "have you" : "has #{@user.first_name}"} been up to?" %>

    <%= day_timeline_pagination_frame_tag @day_timeline do %> <%= render "events/day", day_timeline: @day_timeline %> <% if @day_timeline.next_day %> <%= link_to "Load more…", user_events_path(@user, day: @day_timeline.next_day.strftime("%Y-%m-%d"), **@filter.as_params), class: "day-timeline-pagination-link", data: { frame: day_timeline_pagination_frame_id_for(@day_timeline.next_day), pagination_target: "paginationLink" } %> <% end %> <% end %>
    ================================================ FILE: app/views/users/index.json.jbuilder ================================================ json.array! @page.records, partial: "users/user", as: :user ================================================ FILE: app/views/users/joins/new.html.erb ================================================ <% @page_title = "Great! Now enter your name" %>

    <%= @page_title %>

    <%= form_with scope: "user", url: users_joins_path, class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %>
    <% end %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: app/views/users/show.html.erb ================================================ <% @page_title = @user.name %> <% me_or_you = Current.user == @user ? "me" : @user.first_name %>
    <% if Current.user == @user %> <%= link_to edit_user_path(@user), class: "user-edit-link btn", data: { controller: "tooltip" } do %> <%= icon_tag "pencil" %> Edit profile <% end %> <% end %>
    <%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}" %>

    <%= @user.name %>

    <% if !@user.active? %> <%= @user.name %> is no longer on this account <% elsif !@user.verified? %> Unverified
    A sign-in code has been sent to this email address, but the user has not yet logged in to confirm their identity.
    <% else %> <%= mail_to @user.identity.email_address %> <% end %>
    <% if @user.verified? %>
    <%= link_to "Which cards are assigned to #{me_or_you}?", cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %> <%= link_to "Which cards were added by #{me_or_you}?", cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
    <% end %> <% if Current.user == @user %> <%= link_to my_passkeys_path, class: "btn txt-x-small center" do %> <%= icon_tag "authentication" %> Manage passkeys <% end %> <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %> Sign out of Fizzy on this device <% end %> <% end %>
    <% if Current.user == @user %>
    <%= render "users/theme" %> <%= render "users/transfer", user: @user %> <%= render "users/access_tokens" %> <%= render "users/data_export" %>
    <% end %>
    <% if @user.verified? %> <%= turbo_frame_tag "user_events", src: user_events_path(@user) %> <% end %> ================================================ FILE: app/views/users/show.json.jbuilder ================================================ json.partial! "users/user", user: @user ================================================ FILE: app/views/users/verifications/new.html.erb ================================================ <%= auto_submit_form_with url: users_verifications_path, method: :post %> ================================================ FILE: app/views/webhooks/_delivery.html.erb ================================================ <%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %> <%= delivery.state %>
    <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
    <% end %> ================================================ FILE: app/views/webhooks/_webhook.html.erb ================================================
  • <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" do %> <%= webhook.name %> <% end %> <%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative txt-xx-small flex-item-no-shrink", data: { turbo_confirm: "Are you sure you want to permanently remove this webhook?" } do %> <%= icon_tag "minus" %> Remove <%= webhook.name %> <% end %>
  • ================================================ FILE: app/views/webhooks/_webhook.json.jbuilder ================================================ json.cache! [ webhook, webhook.board ] do json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions) json.payload_url webhook.url json.created_at webhook.created_at.utc json.url board_webhook_url(webhook.board, webhook) json.board webhook.board, partial: "boards/board", as: :board end ================================================ FILE: app/views/webhooks/edit.html.erb ================================================ <% @page_title = @webhook.name %> <% content_for :header do %>
    <%= back_link_to @page_title, @webhook, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %>
    <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>

    <%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

    <%= form.label :actions do %> Events

    Trigger a call to the Payload URL when:

    <% end %> <%= render "webhooks/form/actions", form: form %>
    <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> Save Changes <% end %> <%= link_to "Go back", board_webhooks_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
    ================================================ FILE: app/views/webhooks/event.html.erb ================================================ <%= @event.description_for(Current.user).to_plain_text %> <% if @event.eventable %> <%= link_to "↗︎", polymorphic_url(@event.eventable) %> <% end %> ================================================ FILE: app/views/webhooks/event.json.jbuilder ================================================ json.cache! @event do json.(@event, :id, :action) json.created_at @event.created_at.utc json.eventable do case @event.eventable when Card then json.partial! "cards/card", card: @event.eventable when Comment then json.partial! "cards/comments/comment", comment: @event.eventable end end json.board @event.board, partial: "boards/board", as: :board json.creator @event.creator, partial: "users/user", as: :user end ================================================ FILE: app/views/webhooks/form/_actions.html.erb ================================================
    <%= button_tag "Enable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" } %> · <%= button_tag "Disable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" } %>
      <%= form.collection_check_boxes \ :subscribed_actions, webhook_action_options, :first, :last do |item| %>
    • <% end %>
    ================================================ FILE: app/views/webhooks/index.html.erb ================================================ <% @page_title = "Webhooks" %> <% content_for :header do %>
    <%= link_back_to_board(@board) %>

    <%= @page_title %>

    <% end %> <%= tag.section class: "panel panel--wide shadow center webhooks" do %> <% if @page.records.any? %>
      <%= render partial: "webhooks/webhook", collection: @page.records %>
    <% else %>

    Webhooks can notify another application when something happens in this Fizzy board. You’ll choose which events to subscribe to and provide a URL to receive the data.

    For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.

    <% end %> <%= link_to new_board_webhook_path, class: "btn btn--link" do %> <%= icon_tag "add" %> Set up a new webhook <% end %> <% end %> ================================================ FILE: app/views/webhooks/index.json.jbuilder ================================================ json.array! @page.records, partial: "webhooks/webhook", as: :webhook ================================================ FILE: app/views/webhooks/new.html.erb ================================================ <% @page_title = "Set up a Webhook" %> <% content_for :header do %>
    <%= back_link_to "Webhooks", board_webhooks_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>

    <%= @page_title %>

    <% end %>
    <%= form_with model: @webhook, url: board_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>

    <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name this Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

    <%= form.label :url do %> Payload URL

    This is the URL for the app that will receive payloads from Fizzy.

    <% end %> <%= form.url_field :url, required: true, pattern: "https?://.*", title: "Must be an http:// or https:// URL", class: "input", placeholder: "https://example.com", data: { action: "keydown.esc@document->form#cancel" } %>
    <%= form.label :actions do %> Events

    Trigger a call to the Payload URL when:

    <% end %> <%= render "webhooks/form/actions", form: form %>
    <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> Create Webhook <% end %> <%= link_to "Go back", board_webhooks_path, data: { form_target: "cancel" }, hidden: true %> <% end %>
    ================================================ FILE: app/views/webhooks/show.html.erb ================================================ <% @page_title = @webhook.name %> <% content_for :header do %>
    <%= back_link_to "Webhooks", board_webhooks_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <%= link_to edit_board_webhook_path(@webhook.board_id, @webhook), class: "btn" do %> <%= icon_tag "pencil" %> Edit <% end %>
    <% end %>

    <%= @webhook.name %>

    <%= @webhook.url %>
    <% unless @webhook.active? %>
    <%= button_to "Reactivate", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>
    <% end %>

    Secret

    <%= @webhook.signing_secret %>

    We'll send a X-Webhook-Signature header with each request. You can generate a HMAC using SHA256 of the request body with this secret to verify that the request came from us.

    Subscribed to

    <% if @webhook.subscribed_actions.empty? %>

    This Webhook isn't subscribed to any events. It will never trigger.

    <% else %>
      <% @webhook.subscribed_actions.each do |action| %>
    • <%= webhook_action_label(action) %>
    • <% end %>
    <% end %>

    Deliveries

    <% if @webhook.deliveries.empty? %>

    This Webhook hasn't been triggered yet

    <% else %>
      <%= render partial: "webhooks/delivery", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>
    <% end %>
    ================================================ FILE: app/views/webhooks/show.json.jbuilder ================================================ json.partial! "webhooks/webhook", webhook: @webhook ================================================ FILE: bin/brakeman ================================================ #!/usr/bin/env ruby # frozen_string_literal: true # # This file was generated by Bundler. # # The application 'brakeman' is installed as part of a gem, and # this file is here to facilitate running it. # ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) bundle_binstub = File.expand_path("bundle", __dir__) if File.file?(bundle_binstub) if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") load(bundle_binstub) else abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") end end require "rubygems" require "bundler/setup" load Gem.bin_path("brakeman", "brakeman") ================================================ FILE: bin/bundle-both ================================================ #!/usr/bin/env bash set -euo pipefail echo gum style --foreground 135 --bold "▸ OSS: Gemfile" gum style --foreground 240 "bundle $*" BUNDLE_GEMFILE=Gemfile bundle "$@" echo gum style --foreground 135 --bold "▸ SaaS: Gemfile.saas" gum style --foreground 240 "bundle $*" BUNDLE_GEMFILE=Gemfile.saas bundle "$@" ================================================ FILE: bin/bundle-drift ================================================ #!/usr/bin/env ruby # Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies. # Since Gemfile.saas evals Gemfile, shared gems should have identical versions. # # Usage: # bin/bundle-drift [check] # check for drift (default subcommand) # bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative) # bin/bundle-drift forward # push Gemfile.lock changes into Gemfile.saas.lock require "bundler" require "fileutils" GEMFILE_LOCK = "Gemfile.lock" GEMFILE_SAAS_LOCK = "Gemfile.saas.lock" class GemfileDriftChecker def initialize @oss_lockfile = parse_lockfile(GEMFILE_LOCK) @saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK) end def check find_drift.tap do report it end end private def parse_lockfile(path) Bundler::LockfileParser.new(File.read(path)) end def find_drift oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile) shared_gems = oss_specs.keys & saas_specs.keys shared_gems.filter_map do |name| oss_version, saas_version = oss_specs[name], saas_specs[name] if oss_version != saas_version { name: name, oss: oss_version, saas: saas_version } end end.sort_by { |d| d[:name] } end def specs_hash(lockfile) lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] } end def report(drift) if drift.empty? puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync" else puts "✗ Gemfile lock files have drifted!\n\n" name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock" puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}" drift.each do |d| puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}" end puts "\nRun 'bin/bundle-drift correct' to restore alignment." end end end class GemfileDriftCorrector def correct drift = GemfileDriftChecker.new.check return puts "\nNothing to correct." if drift.empty? puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n" # Save original for diff original_content = File.read(GEMFILE_LOCK) # Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK) # Re-lock: Bundler prunes SaaS-only gems while preserving shared versions puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)" unless system("BUNDLE_GEMFILE=Gemfile bundle lock") File.write(GEMFILE_LOCK, original_content) abort("Failed to lock Gemfile. Restored original.") end puts "\n▸ Verifying alignment" new_drift = GemfileDriftChecker.new.check if new_drift.empty? puts "\n✓ Lock files are now in sync" show_diff(original_content, File.read(GEMFILE_LOCK)) else puts "\n✗ Lock files still have drift after correction." puts " Bundler couldn't resolve to matching versions." puts " Restoring original Gemfile.lock." File.write(GEMFILE_LOCK, original_content) exit 1 end end private def show_diff(original, corrected) require "tempfile" Tempfile.create("gemfile-lock-original") do |f| f.write(original) f.flush diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null` unless diff.empty? puts "\nChanges made to Gemfile.lock:" puts diff end end end end class GemfileDriftForwarder def forward drift = GemfileDriftChecker.new.check return puts "\nNothing to forward." if drift.empty? puts "\nForwarding Gemfile.lock versions into Gemfile.saas.lock...\n\n" original_content = File.read(GEMFILE_SAAS_LOCK) patched_content = original_content.dup drift.each do |d| puts " #{d[:name]} (#{d[:saas]}) → #{d[:name]} (#{d[:oss]})" patched_content.gsub!(/#{Regexp.escape(d[:name])} \(#{Regexp.escape(d[:saas])}([^)]*)\)/) do "#{d[:name]} (#{d[:oss]}#{$1})" end end File.write(GEMFILE_SAAS_LOCK, patched_content) puts "\n▸ Verifying alignment" new_drift = GemfileDriftChecker.new.check if new_drift.empty? puts "\n✓ Lock files are now in sync" show_diff(original_content, patched_content) else puts "\n✗ Lock files still have drift after forwarding." puts " Restoring original Gemfile.saas.lock." File.write(GEMFILE_SAAS_LOCK, original_content) exit 1 end end private def show_diff(original, patched) require "tempfile" Tempfile.create("gemfile-saas-lock-original") do |f| f.write(original) f.flush diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null` unless diff.empty? puts "\nChanges made to Gemfile.saas.lock:" puts diff end end end end case command = ARGV[0] || "check" when "check" exit 1 unless GemfileDriftChecker.new.check.empty? when "correct" GemfileDriftCorrector.new.correct when "forward" GemfileDriftForwarder.new.forward else abort "Usage: bin/bundle-drift [check|correct|forward]" end ================================================ FILE: bin/bundler-audit ================================================ #!/usr/bin/env ruby require_relative "../config/boot" require "bundler/audit/cli" ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") Bundler::Audit::CLI.start ================================================ FILE: bin/ci ================================================ #!/usr/bin/env ruby require_relative "../config/boot" require "active_support/continuous_integration" CI = ActiveSupport::ContinuousIntegration require_relative "../config/ci.rb" ================================================ FILE: bin/dev ================================================ #!/usr/bin/env sh PORT=3006 USE_TAILSCALE=0 USE_PUSH=0 for arg in "$@"; do case $arg in --tailscale) USE_TAILSCALE=1 ;; --push) USE_PUSH=1 ;; esac done if [ "$USE_PUSH" = "1" ]; then if [ ! -f tmp/saas.txt ]; then echo "Enabling SaaS mode for push notifications..." ./bin/rails saas:enable fi echo "Loading push credentials from 1Password..." if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec push-dev)"; then echo "Error: failed to load push credentials. Are you signed into 1Password?" >&2 exit 1 fi if [ -z "$APNS_ENCRYPTION_KEY_B64" ] || [ -z "$APNS_KEY_ID" ]; then echo "Error: Push credentials not set. Missing APNS_ENCRYPTION_KEY_B64 or APNS_KEY_ID." >&2 exit 1 fi fi if [ ! -f tmp/solid-queue.txt ]; then export SOLID_QUEUE_IN_PUMA=false fi if [ -f tmp/oss-config.txt ]; then export OSS_CONFIG=1 fi if [ "$USE_TAILSCALE" = "1" ]; then if ! command -v tailscale >/dev/null 2>&1; then echo "Error: tailscale not found" >&2 exit 1 fi TS_STATUS=$(tailscale status --self --json 2>/dev/null) if [ $? -ne 0 ]; then echo "Error: tailscale not logged in" >&2 exit 1 fi TS_HOSTNAME=$(echo "$TS_STATUS" | jq -r '.Self.DNSName | rtrimstr(".")') TS_PORT="4$PORT" stop_tailscale() { tailscale serve --https=$TS_PORT off >/dev/null 2>&1; } trap stop_tailscale EXIT INT TERM tailscale serve --bg --https=$TS_PORT localhost:$PORT >/dev/null 2>&1 if ! tailscale serve status --json 2>/dev/null | jq -e ".TCP.\"$TS_PORT\"" >/dev/null 2>&1; then echo "Error: tailscale serve failed. On Linux, run once: sudo tailscale set --operator=\$USER" >&2 exit 1 fi echo "Login with david@example.com to: https://$TS_HOSTNAME:$TS_PORT/" else echo "Login with david@example.com to: http://fizzy.localhost:$PORT/" fi ./bin/rails server -b 0.0.0.0 -p $PORT ================================================ FILE: bin/docker-entrypoint ================================================ #!/bin/bash -e # If running the rails server then create or migrate existing database if [ "${1}" == "./bin/thrust" ] && [ "${2}" == "./bin/rails" ] && [ "${3}" == "server" ]; then MIGRATE=1 ./bin/rails db:prepare fi exec "${@}" ================================================ FILE: bin/gitleaks-audit ================================================ #!/usr/bin/env bash if ! which gitleaks > /dev/null 2>&1 ; then echo "gitleaks is not installed, please install it first" 1>&2 exit 1 fi mkdir -p tmp if ! gitleaks dir --redact=50 --report-path tmp/gitleaks-report.json ; then echo "gitleaks found potential secrets, please check tmp/gitleaks-report.json" 1>&2 exit 1 fi exit 0 ================================================ FILE: bin/importmap ================================================ #!/usr/bin/env ruby require_relative '../config/application' require 'importmap/commands' ================================================ FILE: bin/jobs ================================================ #!/usr/bin/env ruby require_relative "../config/environment" require "solid_queue/cli" SolidQueue::Cli.start(ARGV) ================================================ FILE: bin/kamal ================================================ #!/usr/bin/env ruby # frozen_string_literal: true # # This file was generated by Bundler. # # The application 'kamal' is installed as part of a gem, and # this file is here to facilitate running it. # ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) bundle_binstub = File.expand_path("bundle", __dir__) if File.file?(bundle_binstub) if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") load(bundle_binstub) else abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") end end require "rubygems" require_relative "../lib/fizzy" Fizzy.configure_bundle require "bundler/setup" if Fizzy.saas? gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir deploy_config = File.join(gem_path, "config", "deploy.yml") unless ARGV.include?("-c") || ARGV.include?("--config-file") if ARGV.empty? || ARGV.first.start_with?("-") ARGV.unshift("-c", deploy_config) else ARGV.insert(1, "-c", deploy_config) end end end load Gem.bin_path("kamal", "kamal") ================================================ FILE: bin/minio-setup ================================================ #!/usr/bin/env ruby # # Make sure the bucket we rely upon in development exists. # Configuration and credentials are in config/storage.yml # require_relative "../config/environment" require "aws-sdk-s3" # Load storage configuration storage_config = YAML.load(ERB.new(File.read("config/storage.yml")).result) minio_config = storage_config["devminio"] bucket_name = minio_config["bucket"] endpoint = minio_config["endpoint"] access_key = minio_config["access_key_id"] secret_key = minio_config["secret_access_key"] region = minio_config["region"] # Create S3 client s3_client = Aws::S3::Client.new( endpoint: endpoint, access_key_id: access_key, secret_access_key: secret_key, region: region, force_path_style: minio_config["force_path_style"] ) # Check if bucket exists begin s3_client.head_bucket(bucket: bucket_name) puts "Bucket '#{bucket_name}' already exists" rescue Aws::S3::Errors::NotFound # Create the bucket puts "Creating bucket '#{bucket_name}'..." s3_client.create_bucket(bucket: bucket_name) puts "Successfully created bucket '#{bucket_name}'" rescue => e puts "Error checking/creating bucket: #{e.message}" exit 1 end ================================================ FILE: bin/notify_dash_of_deployment ================================================ #!/bin/sh # Example usage: bin/notify_dash_of_deployment $MESSAGE $KAMAL_VERSION $KAMAL_PERFORMER $CURRENT_BRANCH $KAMAL_DESTINATION $KAMAL_RUNTIME if [ -n "$DASH_BASIC_AUTH_SECRET" ]; then jq -n -c --arg message "${1}" \ --arg commit "${2}" \ --arg author "${3}" \ --arg branch "${4}" \ --arg env "${5}" \ --arg duration "${6}" \ '{ "application":"fizzy", "rails_env": $env, "branch": $branch, "deployer": $author, "message": $message, "rev": $commit, "duration": $duration }' | curl --user "$DASH_BASIC_AUTH_SECRET" -H "Content-Type: application/json" --data-binary @- \ https://dash.37signals.com/deploy else echo "WARNING: Dash deploy notification not sent." fi ================================================ FILE: bin/rails ================================================ #!/usr/bin/env ruby APP_PATH = File.expand_path("../config/application", __dir__) require_relative "../lib/fizzy" Fizzy.configure_bundle require_relative "../config/boot" require "rails/commands" if Fizzy.saas? && ENV["RAILS_ENV"] == "test" # the app is not loaded by rails/commands if there are additional arguments to "rails test" require APP_PATH Fizzy::Saas.append_test_paths end ================================================ FILE: bin/rake ================================================ #!/usr/bin/env ruby require_relative '../config/boot' require 'rake' Rake.application.run ================================================ FILE: bin/rubocop ================================================ #!/usr/bin/env ruby # frozen_string_literal: true # # This file was generated by Bundler. # # The application 'rubocop' is installed as part of a gem, and # this file is here to facilitate running it. # ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) bundle_binstub = File.expand_path('bundle', __dir__) if File.file?(bundle_binstub) if File.read(bundle_binstub, 300).include?('This file was generated by Bundler') load(bundle_binstub) else abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") end end require 'rubygems' require 'bundler/setup' load Gem.bin_path('rubocop', 'rubocop') ================================================ FILE: bin/setup ================================================ #!/usr/bin/env bash set -eo pipefail # Prefer app executables app_root="$( cd "$(dirname "$0")/.." pwd )" export PATH="$app_root/bin:$PATH" if [ -e tmp/saas.txt ]; then export SAAS=1 fi if [ -n "$SAAS" ]; then export BUNDLE_GEMFILE="Gemfile.saas" fi # Install gum if needed if ! command -v gum &>/dev/null; then echo echo "▸ Installing gum" if command -v pacman &>/dev/null; then sudo pacman -S --noconfirm gum elif command -v brew &>/dev/null; then brew install gum else echo "Please install gum: https://github.com/charmbracelet/gum" exit 1 fi echo fi # Install mise if needed if ! command -v mise &>/dev/null; then echo echo "▸ Installing mise" if command -v pacman &>/dev/null; then sudo pacman -S --noconfirm mise elif command -v brew &>/dev/null; then brew install mise else echo "Please install mise: https://mise.jdx.dev/installing-mise.html#installation-methods" exit 1 fi echo fi # Install gh if needed if ! command -v gh &>/dev/null; then echo echo "▸ Installing GitHub CLI" if command -v pacman &>/dev/null; then sudo pacman -S --noconfirm github-cli elif command -v brew &>/dev/null; then brew install gh else echo "Please install GitHub CLI: https://github.com/cli/cli#installation" exit 1 fi echo fi step() { local step_name="$1" shift gum style --foreground 135 --bold "▸ $step_name" gum style --foreground 240 "$*" "$@" local exit_code=$? echo return $exit_code } needs_seeding() { if [ "$CI" != "" ]; then return 1 fi has_data=$(bin/rails runner "pp Account.all.any?" 2>/dev/null) if [ "$has_data" = "true" ] ; then return 1 else return 0 fi } oss_mysql_setup() { if ! nc -z localhost 3306 2>/dev/null; then if docker ps -aq -f name=fizzy-mysql | grep -q .; then step "Starting MySQL" docker start fizzy-mysql else step "Setting up MySQL" bash -c ' docker pull mysql:8.4 docker run -d \ --name fizzy-mysql \ -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ -p 3306:3306 \ mysql:8.4 echo "MySQL is starting… (it may take a few seconds)" ' fi fi } echo gum style --foreground 153 " ˚ ∘ ∘ ˚ " gum style --foreground 111 --bold " ∘˚˳°∘° 𝒻𝒾𝓏𝓏𝓎 °∘°˳˚∘ " echo step "Installing Ruby" mise install --yes eval "$(mise hook-env -s bash)" if which pacman >/dev/null 2>&1; then packages=(imagemagick mariadb-libs openslide libvips libheif libwebp libjxl libraw poppler-glib libcgif ffmpeg rav1e svt-av1 gitleaks) if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}" fi elif which brew >/dev/null 2>&1; then packages=(imagemagick openslide vips gitleaks) missing_packages=() for pkg in "${packages[@]}"; do if ! brew list "$pkg" &>/dev/null; then missing_packages+=("$pkg") fi done if [ ${#missing_packages[@]} -gt 0 ]; then step "Installing packages" brew install "${missing_packages[@]}" fi fi bundle config set --local auto_install true step "Installing RubyGems" bundle install if [ -n "$SAAS" ]; then source "$app_root/saas/bin/setup" else if [ "$DATABASE_ADAPTER" = "mysql" ]; then oss_mysql_setup fi fi if [[ $* == *--reset* ]]; then step "Resetting the database" rails db:reset else step "Preparing the database" rails db:prepare if needs_seeding; then step "Seeding the database" rails db:seed fi fi step "Cleaning up logs and tempfiles" rails log:clear tmp:clear gum style --foreground 46 "✓ Done (${SECONDS} sec)" ================================================ FILE: bin/thrust ================================================ #!/usr/bin/env ruby # frozen_string_literal: true # # This file was generated by Bundler. # # The application 'thrust' is installed as part of a gem, and # this file is here to facilitate running it. # ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) bundle_binstub = File.expand_path("bundle", __dir__) if File.file?(bundle_binstub) if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") load(bundle_binstub) else abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") end end require "rubygems" require "bundler/setup" load Gem.bin_path("thruster", "thrust") ================================================ FILE: config/application.rb ================================================ require_relative "boot" require "rails/all" require_relative "../lib/fizzy" require_relative "../lib/action_pack/railtie" Bundler.require(*Rails.groups) module Fizzy class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 8.1 # Include the `lib` directory in autoload paths. Use the `ignore:` option # to list subdirectories that don't contain `.rb` files or that shouldn't # be reloaded or eager loaded. config.autoload_lib ignore: %w[ assets tasks rails_ext ] # 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 Rails.event.debug_mode = true end # Use UUID primary keys for all new tables config.generators do |g| g.orm :active_record, primary_key_type: :uuid end config.action_pack.passkey.draw_routes = false config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: "") } config.mission_control.jobs.http_basic_auth_enabled = false end end ================================================ FILE: config/boot.rb ================================================ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. require "bootsnap/setup" # Speed up boot time by caching expensive operations. ================================================ FILE: config/brakeman.ignore ================================================ { "ignored_warnings": [ { "warning_type": "SQL Injection", "warning_code": 0, "fingerprint": "4ea2e6d704b817af1d896dcdf148a89da8b9e428b3327497631ef8d9ed587307", "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/card/entropic.rb", "line": 19, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at > #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)", "render_path": null, "location": { "type": "method", "class": "Card::Entropic", "method": null }, "user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")", "confidence": "Weak", "cwe_id": [ 89 ], "note": "No user input allowed" }, { "warning_type": "Dangerous Send", "warning_code": 23, "fingerprint": "746ab8227e04231f0002e099e2f670bcc2b8927af85cdc69fab1440002d5c2a6", "check_name": "Send", "message": "User controlled method execution", "file": "app/controllers/events/day_timeline/columns_controller.rb", "line": 19, "link": "https://brakemanscanner.org/docs/warning_types/dangerous_send/", "code": "Current.user.timeline_for(day, :filter => ((Current.user.filters.find(params[:filter_id]) or Current.user.filters.from_params(filter_params)))).public_send(\"#{params[:id]}_column\")", "render_path": null, "location": { "type": "method", "class": "Events::DayTimeline::ColumnsController", "method": "set_column" }, "user_input": "params[:id]", "confidence": "High", "cwe_id": [ 77 ], "note": "" }, { "warning_type": "SSL Verification Bypass", "warning_code": 71, "fingerprint": "8e566e1a52e48940c840541ea4e33f41a39dc0f2a97eb83c52e76f9582667a3c", "check_name": "SSLVerify", "message": "SSL certificate verification was bypassed", "file": "app/models/zip_file/remote_io.rb", "line": 41, "link": "https://brakemanscanner.org/docs/warning_types/ssl_verification_bypass/", "code": "Net::HTTP.new(@uri.hostname, @uri.port).verify_mode = OpenSSL::SSL::VERIFY_NONE", "render_path": null, "location": { "type": "method", "class": "ZipFile::RemoteIO", "method": "with_http" }, "user_input": null, "confidence": "High", "cwe_id": [ 295 ], "note": "Required for internal PureStorage self-signed certificates. Only used for trusted internal storage URLs." }, { "warning_type": "Mass Assignment", "warning_code": 70, "fingerprint": "ac0fa1970dea215e2f47a5676ffd63d8728951e361da12a53dd424bf6dc46f2a", "check_name": "MassAssignment", "message": "Specify exact keys allowed for mass assignment instead of using `permit!` which allows any keys", "file": "app/helpers/pagination_helper.rb", "line": 14, "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", "code": "params.permit!", "render_path": null, "location": { "type": "method", "class": "PaginationHelper", "method": "pagination_link" }, "user_input": null, "confidence": "Medium", "cwe_id": [ 915 ], "note": "" }, { "warning_type": "Remote Code Execution", "warning_code": 24, "fingerprint": "f13f9f972c3f026ab4509a66ac284b8b7c1ba6191a3c4c89d2e9fb4584478f6d", "check_name": "UnsafeReflection", "message": "Unsafe reflection method `safe_constantize` called on model attribute", "file": "app/models/notifier.rb", "line": 8, "link": "https://brakemanscanner.org/docs/warning_types/remote_code_execution/", "code": "\"Notifier::#{Event.eventable.class}EventNotifier\".safe_constantize", "render_path": null, "location": { "type": "method", "class": "Notifier", "method": "s(:self).for" }, "user_input": "Event.eventable.class", "confidence": "Medium", "cwe_id": [ 470 ], "note": "" }, { "warning_type": "SQL Injection", "warning_code": 0, "fingerprint": "bbd8fe3cbbc6b393df770d3142d6fa46e2b9441b21f48a52175211acaae4efad", "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/card/entropic.rb", "line": 10, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", "code": "active.joins(:board => :account).left_outer_joins(:board => :entropy).joins(\"LEFT OUTER JOIN entropies AS account_entropies ON account_entropies.account_id = accounts.id AND account_entropies.container_type = 'Account' AND account_entropies.container_id = accounts.id\").where(\"last_active_at <= #{connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")}\", Time.now)", "render_path": null, "location": { "type": "method", "class": "Card::Entropic", "method": null }, "user_input": "connection.date_subtract(\"?\", \"COALESCE(entropies.auto_postpone_period, account_entropies.auto_postpone_period)\")", "confidence": "Weak", "cwe_id": [ 89 ], "note": "No user input allowed" } ], "brakeman_version": "8.0.1" } ================================================ FILE: config/cable.yml ================================================ cable: &cable adapter: solid_cable connects_to: database: writing: cable reading: cable polling_interval: 0.1.seconds message_retention: 1.day development: *cable test: adapter: test beta: *cable staging: *cable production: *cable ================================================ FILE: config/cache.yml ================================================ default_options: &default_options store_options: max_age: <%= 60.days.to_i %> namespace: <%= "#{Rails.env}#{"-#{ENV["CACHE_NAMESPACE"]}" if ENV["CACHE_NAMESPACE"]}" %> default_connection: &default_connection database: cache default: &default <<: *default_connection <<: *default_options development: *default test: *default_options beta: *default staging: *default production: *default ================================================ FILE: config/ci.rb ================================================ # Run using bin/ci require_relative "../lib/fizzy" OSS_ENV = "SAAS=false BUNDLE_GEMFILE=Gemfile" SAAS_ENV = "SAAS=true BUNDLE_GEMFILE=Gemfile.saas" SYSTEM_TEST_ENV = "PARALLEL_WORKERS=1" # system tests can't run reliably in parallel CI.run do step "Setup", "bin/setup --skip-server" step "Style: Ruby", "bin/rubocop" step "Gemfile: Drift check", "bin/bundle-drift check" step "Security: Gem audit", "bin/bundler-audit check --update" step "Security: Importmap audit", "bin/importmap audit" step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" step "Security: Gitleaks audit", "bin/gitleaks-audit" if Fizzy.saas? step "Tests: SaaS", "#{SAAS_ENV} bin/rails test" step "Tests: SaaS System", "#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system" step "Tests: OSS", "#{OSS_ENV} bin/rails test" step "Tests: OSS System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system" else step "Tests: SQLite", "#{OSS_ENV} bin/rails test" step "Tests: SQLite System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system" end if success? step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" else failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." end end ================================================ FILE: config/database.mysql.yml ================================================ default: &default adapter: trilogy host: <%= ENV.fetch("MYSQL_HOST", "127.0.0.1") %> port: <%= ENV.fetch("MYSQL_PORT", "3306") %> username: <%= ENV.fetch("MYSQL_USER", "root") %> password: <%= ENV["MYSQL_PASSWORD"] %> pool: 50 ssl_mode: <%= ENV["MYSQL_SSL_MODE"] %> timeout: 5000 development: primary: <<: *default database: fizzy_development cable: <<: *default database: fizzy_development_cable migrations_paths: db/cable_migrate test: primary: <<: *default database: fizzy_test cable: <<: *default database: fizzy_test_cable migrations_paths: db/cable_migrate production: primary: <<: *default database: fizzy_production cable: <<: *default database: fizzy_production_cable migrations_paths: db/cable_migrate queue: <<: *default database: fizzy_production_queue migrations_paths: db/queue_migrate cache: <<: *default database: fizzy_production_cache migrations_paths: db/cache_migrate ================================================ FILE: config/database.sqlite.yml ================================================ default: &default adapter: sqlite3 pool: 5 timeout: 5000 development: primary: <<: *default database: storage/development.sqlite3 schema_dump: schema_sqlite.rb cable: <<: *default database: storage/development_cable.sqlite3 migrations_paths: db/cable_migrate test: primary: <<: *default database: storage/test.sqlite3 schema_dump: schema_sqlite.rb cable: <<: *default database: storage/test_cable.sqlite3 migrations_paths: db/cable_migrate production: primary: <<: *default database: storage/production.sqlite3 schema_dump: schema_sqlite.rb cable: <<: *default database: storage/production_cable.sqlite3 migrations_paths: db/cable_migrate cache: <<: *default database: storage/production_cache.sqlite3 migrations_paths: db/cache_migrate queue: <<: *default database: storage/production_queue.sqlite3 migrations_paths: db/queue_migrate ================================================ FILE: config/database.yml ================================================ <% config_path = if Fizzy.saas? gem_path = Rails.root.join("saas").to_s File.join(gem_path, "config", "database.yml") else File.join("config", "database.#{Fizzy.db_adapter}.yml") end %> <%= ERB.new(File.read(config_path)).result %> ================================================ FILE: config/deploy.yml ================================================ # Name of this app service: fizzy image: fizzy #-- About your deployment --# # Where to deploy fizzy servers: web: - fizzy.example.com # Set your server name here # How you connect to your server ssh: user: root # If you use a different username to SSH to your server, specify it here # Automatic SSL proxy: ssl: true # Set this to false if you *don't* want SSL host: fizzy.example.com # Set your server name here to use automatic SSL # Your application configuration (secrets come from .kamal/secrets). env: secret: - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY - SMTP_USERNAME - SMTP_PASSWORD clear: BASE_URL: https://fizzy.example.com # The public URL of your Fizzy instance MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email MULTI_TENANT: false # Set to true to allow multiple accounts to sign up SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container #-- General configuration --# # Use a local registry to deploy registry: server: localhost:5555 # Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a # Rails console in production. aliases: console: app exec --interactive --reuse "bin/rails console" shell: app exec --interactive --reuse "bash" logs: app logs -f dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password" # Use a persistent storage volume for sqlite database files and local Active Storage files. # Recommended to change this to a mounted volume path that is backed up off server. volumes: - "fizzy_storage:/rails/storage" # Bridge fingerprinted assets, like JS and CSS, between versions to avoid # hitting 404 on in-flight requests. Combines all files from new and old # version inside the asset_path. asset_path: /rails/public/assets # Configure the image builder. builder: arch: amd64 ================================================ FILE: config/environment.rb ================================================ # Load the Rails application. require_relative "application" # Initialize the Rails application. Rails.application.initialize! ================================================ FILE: config/environments/beta.rb ================================================ require_relative "production" ================================================ FILE: config/environments/development.rb ================================================ require "active_support/core_ext/integer/time" Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded any time # it changes. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. config.enable_reloading = true # Do not eager load code on boot. config.eager_load = false # Show full error reports. config.consider_all_requests_local = true # Enable server timing config.server_timing = true # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. if Rails.root.join("tmp/caching-dev.txt").exist? config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true config.cache_store = :memory_store config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}" } else config.action_controller.perform_caching = false config.cache_store = :null_store end # Store uploaded files on the local file system (see config/storage.yml for options). if Rails.root.join("tmp/minio-dev.txt").exist? config.active_storage.service = :devminio config.x.content_security_policy.connect_src = "http://minio.localhost:39000" config.x.content_security_policy.img_src = "http://minio.localhost:39000" else config.active_storage.service = :local end # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false config.action_mailer.perform_caching = false # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log # Raise exceptions for disallowed deprecations. config.active_support.disallowed_deprecation = :raise # Tell Active Support which deprecation messages to disallow. config.active_support.disallowed_deprecation_warnings = [] # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load # Highlight code that triggered database queries in logs. config.active_record.verbose_query_logs = true # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true # Uncomment if you wish to allow Action Cable access from any origin. # config.action_cable.disable_request_forgery_protection = true # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] if Rails.root.join("tmp/email-dev.txt").exist? config.action_mailer.delivery_method = :letter_opener config.action_mailer.perform_deliveries = true else config.action_mailer.raise_delivery_errors = false end config.hosts = [ "fizzy.localhost", "localhost", "127.0.0.1", /fizzy-\d+/, # review apps: fizzy-123, fizzy-456:3000 /.*\.ts\.net/, # tailscale serve: hostname.tail1234.ts.net /.*\.nip\.io/ # nip.io for mobile apps ] # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" } end ================================================ FILE: config/environments/production.rb ================================================ require "active_support/core_ext/integer/time" Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # Email provider Settings # # SMTP setting can be configured via environment variables. # For other configuration options, consult the Action Mailer documentation. if smtp_address = ENV["SMTP_ADDRESS"].presence config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: smtp_address, port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i, domain: ENV.fetch("SMTP_DOMAIN", nil), user_name: ENV.fetch("SMTP_USERNAME", nil), password: ENV.fetch("SMTP_PASSWORD", nil), authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"), tls: ENV["SMTP_TLS"] == "true", openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"] } end # Base URL for links in emails and other external references. # Set BASE_URL to your instance's public URL (e.g., https://fizzy.example.com) if base_url = ENV["BASE_URL"].presence uri = URI.parse(base_url) url_options = { host: uri.host, protocol: uri.scheme } url_options[:port] = uri.port if uri.port != uri.default_port routes.default_url_options = url_options config.action_mailer.default_url_options = url_options end # Code is not reloaded between requests. config.enable_reloading = false # Eager load code on boot. This eager loads most of Rails and # your application in memory, allowing both threaded web servers # and those relying on copy on write to perform better. # Rake tasks automatically ignore this option for performance. config.eager_load = true # Full error reports are disabled and caching is turned on. config.consider_all_requests_local = false config.action_controller.perform_caching = true # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). # config.require_master_key = true config.public_file_server.enabled = true config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{5.minutes.to_i}" } # Select Active Storage service via env var; default to local disk. # Don't overwrite if it's already been set (e.g. by fizzy-saas) if config.active_storage.service.blank? config.active_storage.service = ENV.fetch("ACTIVE_STORAGE_SERVICE", "local").to_sym end # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" # Specifies the header that your server uses for sending files. # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX # Mount Action Cable outside main process or domain. # config.action_cable.mount_path = nil # config.action_cable.url = "wss://example.com/cable" # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] # Set DISABLE_SSL=true to disable all SSL options, rather than specify each individually ssl_enabled = "true" unless ENV["DISABLE_SSL"] == "true" # Assume all access to the app is happening through a SSL-terminating reverse proxy. # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. config.assume_ssl = ENV.fetch("ASSUME_SSL", ssl_enabled) == "true" # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = ENV.fetch("FORCE_SSL", ssl_enabled) == "true" # Log to STDOUT by default config.logger = ActiveSupport::Logger.new(STDOUT) .tap { |logger| logger.formatter = ::Logger::Formatter.new } .then { |logger| ActiveSupport::TaggedLogging.new(logger) } # Prepend all log lines with the following tags. config.log_tags = [ :request_id ] # "info" includes generic and useful information about system operation, but avoids logging too much # information to avoid inadvertent exposure of personally identifiable information (PII). If you # want to log everything, set the level to "debug". config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") # Use a different cache store in production. config.cache_store = :solid_cache_store # Use a real queuing backend for Active Job (and separate queues per environment). config.active_job.queue_adapter = :solid_queue config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } } # config.active_job.queue_name_prefix = "fizzy_production" config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true # Don't log any deprecations. config.active_support.report_deprecations = false # Do not dump schema after migrations. config.active_record.dump_schema_after_migration = false # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end ================================================ FILE: config/environments/staging.rb ================================================ require_relative "production" ================================================ FILE: config/environments/test.rb ================================================ require "active_support/core_ext/integer/time" # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # While tests run files are not watched, reloading is not necessary. config.enable_reloading = false # Eager loading loads your entire application. When running a single test locally, # this is usually not necessary, and can slow down your test suite. However, it's # recommended that you enable it in continuous integration systems to ensure eager # loading is working properly before deploying your code. config.eager_load = ENV["CI"].present? # Configure public file server for tests with Cache-Control for performance. config.public_file_server.enabled = true config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{1.hour.to_i}" } # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false config.cache_store = :null_store # Render exception templates for rescuable exceptions and raise for other exceptions. config.action_dispatch.show_exceptions = :rescuable # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false # Store uploaded files on the local file system in a temporary directory. config.active_storage.service = :test config.action_mailer.perform_caching = false # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test # Set host to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: "example.com" } # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr # Raise exceptions for disallowed deprecations. config.active_support.disallowed_deprecation = :raise # Tell Active Support which deprecation messages to disallow. config.active_support.disallowed_deprecation_warnings = [] # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true # Load test helpers config.autoload_paths += %w[ test/test_helpers ] # Enable multi-tenant mode for tests config.x.multi_tenant.enabled = true end ================================================ FILE: config/importmap.rb ================================================ # Pin npm packages by running ./bin/importmap pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/turbo/offline", to: "turbo-offline.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js" pin "@rails/request.js", to: "@rails--request.js" # @0.0.13 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" pin_all_from "app/javascript/lib", under: "lib" pin_all_from "app/javascript/initializers", under: "initializers" pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge" pin "lexxy" pin "@rails/activestorage", to: "activestorage.esm.js" pin "@rails/actiontext", to: "actiontext.esm.js" ================================================ FILE: config/initializers/action_text.rb ================================================ module ActionText module Extensions module RichText extend ActiveSupport::Concern included do # This overrides the default :embeds association! has_many_attached :embeds do |attachable| ::Attachments::VARIANTS.each do |variant_name, variant_options| attachable.variant variant_name, **variant_options, process: :immediately end end end # Delegate storage tracking to the parent record (Card, Comment, Board, etc.) def storage_tracked_record record.try(:storage_tracked_record) end def accessible_to?(user) record.try(:accessible_to?, user) || record.try(:publicly_accessible?) end def publicly_accessible? record.try(:publicly_accessible?) end end end end ActiveSupport.on_load(:action_text_rich_text) do include ActionText::Extensions::RichText end ================================================ FILE: config/initializers/active_job.rb ================================================ # frozen_string_literal: true # inspired from code in ActiveRecord::Tenanted module FizzyActiveJobExtensions extend ActiveSupport::Concern prepended do attr_reader :account self.enqueue_after_transaction_commit = true end def initialize(...) super @account = Current.account end def serialize super.merge({ "account" => @account&.to_gid }) end def deserialize(job_data) super if _account = job_data.fetch("account", nil) @account = GlobalID::Locator.locate(_account) end end def perform_now if account.present? Current.with_account(account) { super } else super end end end ActiveSupport.on_load(:active_job) do prepend FizzyActiveJobExtensions end ================================================ FILE: config/initializers/active_storage.rb ================================================ ActiveSupport.on_load(:active_storage_attachment) do include Storage::AttachmentTracking end ActiveSupport.on_load(:active_storage_blob) do ActiveStorage::DiskController.after_action only: :show do expires_in 5.minutes, public: true end end ActiveSupport.on_load(:action_text_content) do # Install our extensions after ActionText::Engine's ActiveSupport.on_load(:active_storage_blob) do # Ensure all s have a "url" attribute that's a relative # path (for portability across host name changes, beta environments, etc). def to_rich_text_attributes(*) super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true) end end end # Don't configure replica connections for ActiveStorage::Record. # When ActiveStorage uses `connects_to`, it creates a separate connection pool # from ApplicationRecord. This causes after_commit callbacks to fire in # non-deterministic order - the Attachment's create_variants callback can fire # before the User model's upload callback, causing FileNotFoundError when # using `process: :immediately` for variants. # See: https://github.com/rails/rails/issues/53694 ActiveSupport.on_load(:active_storage_record) do configure_replica_connections end module ActiveStorageControllerExtensions extend ActiveSupport::Concern included do before_action do # Add script_name so that Disk Service will generate correct URLs for uploads ActiveStorage::Current.url_options = { protocol: request.protocol, host: request.host, port: request.port, script_name: request.script_name } end end end module ActiveStorageDirectUploadsControllerExtensions extend ActiveSupport::Concern included do include Authentication include Authorization skip_forgery_protection if: :authenticate_by_bearer_token end end Rails.application.config.to_prepare do ActiveStorage::BaseController.include ActiveStorageControllerExtensions ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions end ================================================ FILE: config/initializers/active_storage_no_reuse.rb ================================================ # Enforce storage ledger integrity by preventing blob reuse in tracked contexts. # # Two invariants: # 1. Account match: blob.account_id == record.account_id (multi-tenant safety) # 2. No reuse within tracked contexts: a blob can only have one tracked attachment # # With per-attachment reconcile, blob reuse inside an account wouldn't break correctness - # ledger would still count each attach, and reconcile would agree. However, we intentionally # forbid reuse (except templates) as a product/control decision: # - Simpler mental model (one blob = one attachment) # - Prevents accidental quota manipulation via direct blob_id reuse # - Cleaner audit trail in ledger entries # # Scope note: The no-reuse validation only blocks reuse when the *new* attachment is tracked # AND only checks *existing* attachments in Storage::TRACKED_RECORD_TYPES. A blob first # attached to an untracked type (avatar/export) could theoretically be reused in a tracked # context. This is acceptable - user-accessible blob IDs from untracked contexts are # basically nonexistent in practice. # # Exception: ActionText embeds are allowed to reuse blobs to support copy/paste. ActiveSupport.on_load(:active_storage_attachment) do validate :blob_account_matches_record, on: :create validate :no_tracked_blob_reuse, on: :create private # Multi-tenant safety: blob must belong to same account as record # NOTE: Skips validation if record.account is nil. This is a theoretical bypass # if someone attaches before account assignment, but our flows assign account # before attachment. Global/unaccounted attachments (Identity/User avatars, exports) # bypass tenancy checks via try(:account) returning nil - this is intentional as # these classes don't participate in storage tracking. def blob_account_matches_record if record&.try(:account).present? && !whitelisted_for_cross_account? unless blob&.account_id == record.account.id errors.add(:blob_id, "blob account must match record account") end end end # Ledger integrity: blob can only have one tracked attachment def no_tracked_blob_reuse tracked_record = record&.try(:storage_tracked_record) if tracked_record.present? && !whitelisted_for_cross_account? && !(record_type == "ActionText::RichText" && name == "embeds") # Check for existing attachment of this blob in tracked contexts # Uses Storage::TRACKED_RECORD_TYPES constant to stay generic existing = ActiveStorage::Attachment .where(blob_id: blob_id) .where(record_type: Storage::TRACKED_RECORD_TYPES) .where.not(id: id) .exists? if existing errors.add(:blob_id, "cannot reuse blob in tracked storage context") end end end def whitelisted_for_cross_account? # Only template account blobs can be reused cross-tenant. # When TEMPLATE_ACCOUNT_ID is nil, no exemptions are granted. Storage::TEMPLATE_ACCOUNT_ID.present? && blob&.account_id == Storage::TEMPLATE_ACCOUNT_ID end end ================================================ FILE: config/initializers/active_storage_purge_on_last_attachment.rb ================================================ # Fizzy-specific override: ActiveStorage's default purge path uses `delete`, # which skips attachment callbacks. We need `destroy` so storage ledger detaches # are recorded and reused blobs (ActionText embeds) aren't purged until the # last attachment is gone. Keep this local to Fizzy; it's not a Rails default. module ActiveStorage module PurgeOnLastAttachment def purge @purge_mode = :purge destroy purge_blob_if_last(:purge) if destroyed? ensure @purge_mode = nil end def purge_later @purge_mode = :purge_later destroy purge_blob_if_last(:purge_later) if destroyed? ensure @purge_mode = nil end private def purge_dependent_blob_later if (record.nil? || dependent == :purge_later) && !@purge_mode purge_blob_if_last(:purge_later) end end def purge_blob_if_last(mode) if blob && !blob.attachments.exists? mode == :purge ? blob.purge : blob.purge_later end end end end ActiveSupport.on_load(:active_storage_attachment) do prepend ActiveStorage::PurgeOnLastAttachment end ================================================ FILE: config/initializers/assets.rb ================================================ # Be sure to restart your server when you modify this file. # Version of your assets, change this if you want to expire all your assets. Rails.application.config.assets.version = "1.0" # Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path ================================================ FILE: config/initializers/autotuner.rb ================================================ # Enable autotuner. Alternatively, call Autotuner.sample_ratio= with a value # between 0 and 1.0 to sample on a portion of instances. Autotuner.enabled = true # This callback is called whenever a suggestion is provided by this gem. # Log to structured logging for query/analysis in Loki. This is called # once per autotuner heuristic. Autotuner.reporter = proc do |heuristic_report| report = heuristic_report.to_s Rails.logger.info "GCAUTOTUNE: #{report}" RailsStructuredLogging.instrument_script "autotuner" do Rails.logger.info report.to_s end if defined? RailsStructuredLogging end ================================================ FILE: config/initializers/content_security_policy.rb ================================================ # Be sure to restart your server when you modify this file. # Define an application-wide Content Security Policy. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy # # Directives are configurable via environment variables with fallback to config.x # settings. This allows fizzy-saas (or other deployments) to extend the base policy # without duplicating it. # # ENV vars (space-separated sources): # CSP_DEFAULT_SRC, CSP_SCRIPT_SRC, CSP_STYLE_SRC, CSP_CONNECT_SRC, CSP_FRAME_SRC, # CSP_IMG_SRC, CSP_FONT_SRC, CSP_MEDIA_SRC, CSP_WORKER_SRC, CSP_FRAME_ANCESTORS, # CSP_FORM_ACTION, CSP_REPORT_URI, CSP_REPORT_ONLY, DISABLE_CSP # # config.x.content_security_policy.* (string, space-separated string, or array): # script_src, style_src, connect_src, frame_src, img_src, font_src, media_src, # worker_src, frame_ancestors, form_action, report_uri, report_only Rails.application.configure do # Helper to get additional CSP sources from ENV or config.x. # Supports: nil, string, space-separated string, or array. sources = ->(directive) do env_key = "CSP_#{directive.to_s.upcase}" value = if ENV.key?(env_key) ENV[env_key] else config.x.content_security_policy.send(directive) end case value when nil then [] when Array then value when String then value.split else [] end end # Report URI and report-only mode report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri } report_only = if ENV.key?("CSP_REPORT_ONLY") ENV["CSP_REPORT_ONLY"] == "true" else config.x.content_security_policy.report_only end # Generate nonces for importmap and inline scripts config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) } config.content_security_policy_nonce_directives = %w[ script-src ] config.content_security_policy do |policy| policy.default_src :self, *sources.(:default_src) policy.script_src :self, *sources.(:script_src) policy.connect_src :self, *sources.(:connect_src) policy.frame_src :self, *sources.(:frame_src) # Don't fight user tools: permit inline styles, data:/https: sources, and # blob: workers for accessibility extensions, privacy tools, and custom fonts. policy.style_src :self, :unsafe_inline, *sources.(:style_src) policy.img_src :self, "blob:", "data:", "https:", *sources.(:img_src) policy.font_src :self, "data:", "https:", *sources.(:font_src) policy.media_src :self, "blob:", "data:", "https:", *sources.(:media_src) policy.worker_src :self, "blob:", *sources.(:worker_src) # Security-critical defaults (not configurable) policy.object_src :none policy.base_uri :none policy.form_action :self, *sources.(:form_action) policy.frame_ancestors :self, *sources.(:frame_ancestors) # Specify URI for violation reports (e.g., Sentry CSP endpoint) policy.report_uri report_uri if report_uri end # Report violations without enforcing the policy. config.content_security_policy_report_only = report_only end unless ENV["DISABLE_CSP"] ================================================ FILE: config/initializers/database_role_logging.rb ================================================ require_relative "extensions" class DatabaseRoleLogger def initialize(app) @app = app end def call(env) Rails.logger.tagged(ActiveRecord::Base.current_role.to_s) do @app.call(env) end end end if ActiveRecord::Base.replica_configured? Rails.application.config.middleware.insert_after ActiveRecord::Middleware::DatabaseSelector, DatabaseRoleLogger end ================================================ FILE: config/initializers/error_context.rb ================================================ # Lazily add identity and account context to error reports. # Only evaluated when an error is actually reported. Rails.error.add_middleware ->(error, context:, **) do context.merge \ identity_id: Current.identity&.id, account_id: Current.account&.external_account_id end ================================================ FILE: config/initializers/extensions.rb ================================================ Dir["#{Rails.root}/lib/rails_ext/*"].each { |path| require "rails_ext/#{File.basename(path)}" } ================================================ FILE: config/initializers/filter_parameter_logging.rb ================================================ # Be sure to restart your server when you modify this file. # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. # Use this to limit dissemination of sensitive information. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += %i[ passw secret token _key crypt salt certificate otp ssn ] ================================================ FILE: config/initializers/inflections.rb ================================================ # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format. Inflections # are locale specific, and you may define rules for as many different # locales as you wish. All of these examples are active by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.plural /^(ox)$/i, "\\1en" # inflect.singular /^(ox)en/i, "\\1" # inflect.irregular "person", "people" # inflect.uncountable %w( fish sheep ) # end # These inflection rules are supported but not enabled by default: ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "SQLite" inflect.acronym "IO" inflect.singular "quotas", "quota" inflect.plural "quota", "quotas" end ================================================ FILE: config/initializers/mission_control.rb ================================================ Rails.application.config.before_initialize do MissionControl::Jobs.base_controller_class = "AdminController" MissionControl::Jobs.show_console_help = false end ================================================ FILE: config/initializers/multi_db.rb ================================================ require "deployment" require_relative "extensions" if ActiveRecord::Base.replica_configured? Rails.application.configure do config.active_record.database_selector = { delay: 0.seconds } config.active_record.database_resolver = Deployment::DatabaseResolver config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session end end ================================================ FILE: config/initializers/multi_tenant.rb ================================================ Rails.application.configure do config.after_initialize do Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || config.x.multi_tenant.enabled == true end end ================================================ FILE: config/initializers/passkeys.rb ================================================ Rails.application.config.to_prepare do ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid end ================================================ FILE: config/initializers/permissions_policy.rb ================================================ # Be sure to restart your server when you modify this file. # Define an application-wide HTTP permissions policy. For further # information see: https://developers.google.com/web/updates/2018/06/feature-policy # Rails.application.config.permissions_policy do |policy| # policy.camera :none # policy.gyroscope :none # policy.microphone :none # policy.usb :none # policy.fullscreen :self # policy.payment :self, "https://secure.example.com" # end ================================================ FILE: config/initializers/push_notifications.rb ================================================ Rails.application.config.to_prepare do Notification.register_push_target(:web) end ================================================ FILE: config/initializers/rack_mini_profiler.rb ================================================ if defined?(Rack::MiniProfiler) Rack::MiniProfiler.config.tap do |config| config.position = "top-right" config.enable_hotwire_turbo_drive_support = true config.pre_authorize_cb = ->(_env) { !Rails.env.test? && File.exist?(Rails.root.join("tmp/rack-mini-profiler-dev.txt")) } end end ================================================ FILE: config/initializers/sanitization.rb ================================================ Rails.application.config.after_initialize do Rails::HTML5::SafeListSanitizer.allowed_tags.merge(%w[ s table tr td th thead tbody details summary video source ]) Rails::HTML5::SafeListSanitizer.allowed_attributes.merge(%w[ data-turbo-frame data-lightbox-target data-lightbox-caption-value controls type width ]) # ugh, see https://github.com/rails/rails/issues/54478 which I need to fix upstream --mike ActionText::ContentHelper.allowed_tags = Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + [ ActionText::Attachment.tag_name, "figure", "figcaption" ] + ActionText::ContentHelper.allowed_tags.to_a ActionText::ContentHelper.allowed_attributes = Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + ActionText::Attachment::ATTRIBUTES + ActionText::ContentHelper.allowed_attributes.to_a end ================================================ FILE: config/initializers/sqlite_schema_dumper.rb ================================================ # Fix for SQLite FTS5 virtual table schema dumping # Rails has a bug where it doesn't handle FTS5 content= and content_rowid= options module SQLiteFTS5SchemaDumperFix # Override the virtual_tables method to handle FTS5 syntax properly def virtual_tables(stream) # Query sqlite_master for all virtual tables virtual_table_sqls = @connection.select_rows( "SELECT name, sql FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%'" ) virtual_table_sqls.each do |table_name, sql| # Just output the raw SQL since create_virtual_table doesn't handle our syntax stream.puts " execute #{sql.inspect}" stream.puts end end end ActiveSupport.on_load(:active_record_sqlite3adapter) do ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SQLiteFTS5SchemaDumperFix) end ================================================ FILE: config/initializers/table_definition_column_limits.rb ================================================ # Apply MySQL-compatible column limits when defining tables. # # For string columns: defaults to 255 (MySQL's VARCHAR default) # # For text columns: converts MySQL's `size:` option to equivalent limits: # - (blank/default): 65,535 (TEXT) # - size: :tiny: 255 (TINYTEXT) # - size: :medium: 16,777,215 (MEDIUMTEXT) # - size: :long: 4,294,967,295 (LONGTEXT) # module TableDefinitionColumnLimits TEXT_SIZE_TO_LIMIT = { tiny: 255, medium: 16_777_215, long: 4_294_967_295 }.freeze TEXT_DEFAULT_LIMIT = 65_535 STRING_DEFAULT_LIMIT = 255 def column(name, type, **options) if type == :string options[:limit] ||= STRING_DEFAULT_LIMIT end if type == :text || type == :binary if options.key?(:size) size = options.delete(:size) options[:limit] ||= TEXT_SIZE_TO_LIMIT.fetch(size) do raise ArgumentError, "Unknown text size: #{size.inspect}. Use :tiny, :medium, or :long" end elsif type == :text options[:limit] ||= TEXT_DEFAULT_LIMIT end end super end end # For SQLite: append inline CHECK constraints to enforce string/text length limits. # since SQLite doesn't natively enforce VARCHAR/TEXT length limits. module SQLiteColumnLimitCheckConstraints def add_column_options!(sql, options) super column = options[:column] if column && column.limit && %i[string text].include?(column.type) check_expr = if column.type == :string # VARCHAR limits are in characters %(length("#{column.name}") <= #{column.limit}) else # TEXT limits are in bytes %(length(CAST("#{column.name}" AS BLOB)) <= #{column.limit}) end sql << " CHECK(#{check_expr})" end sql end end ActiveSupport.on_load(:active_record) do ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionColumnLimits) end ActiveSupport.on_load(:active_record_sqlite3adapter) do ActiveRecord::ConnectionAdapters::SQLite3::SchemaCreation.prepend(SQLiteColumnLimitCheckConstraints) end ================================================ FILE: config/initializers/tenanting/account_slug.rb ================================================ module AccountSlug PATTERN = /(\d+)/ PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/ class Extractor def initialize(app) @app = app end # We're using account id prefixes in the URL path. Rather than namespace # all our routes, we're "mounting" the Rails app at this URL prefix. def call(env) request = ActionDispatch::Request.new(env) # $1, $2, $' == script_name, slug, path_info if request.script_name && request.script_name =~ PATH_INFO_MATCH # Likely due to restarting the action cable connection after upgrade env["fizzy.external_account_id"] = AccountSlug.decode($2) elsif request.path_info =~ PATH_INFO_MATCH # Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME request.engine_script_name = request.script_name = $1 request.path_info = $'.empty? ? "/" : $' # Stash the account's Queenbee ID. env["fizzy.external_account_id"] = AccountSlug.decode($2) end if env["fizzy.external_account_id"] account = Account.find_by(external_account_id: env["fizzy.external_account_id"]) Current.with_account(account) do @app.call env end else Current.without_account do @app.call env end end end end def self.decode(slug) slug.to_i end def self.encode(id) id.to_s end end Rails.application.config.middleware.insert_after Rack::TempfileReaper, AccountSlug::Extractor ================================================ FILE: config/initializers/tenanting/turbo.rb ================================================ module TurboStreamsJobExtensions extend ActiveSupport::Concern class_methods do def render_format(format, **rendering) if Current.account.present? ApplicationController.renderer.new(script_name: Current.account.slug).render(formats: [ format ], **rendering) else super end end end end Rails.application.config.after_initialize do Turbo::StreamsChannel.prepend TurboStreamsJobExtensions end ================================================ FILE: config/initializers/uuid_framework_models.rb ================================================ # Inject account associations into Rails framework models Rails.application.config.to_prepare do ActionText::RichText.belongs_to :account, default: -> { record.account } ActiveStorage::Attachment.belongs_to :account, default: -> { record.account } ActiveStorage::Blob.belongs_to :account, default: -> { Current.account } ActiveStorage::VariantRecord.belongs_to :account, default: -> { blob.account } end ================================================ FILE: config/initializers/uuid_primary_keys.rb ================================================ # Automatically use UUID type for all binary(16) columns and generate defaults module UuidPrimaryKeyDefault def load_schema! define_uuid_primary_key_pending_default super end private def define_uuid_primary_key_pending_default if uuid_primary_key? pending_attribute_modifications << PendingUuidDefault.new(primary_key) end rescue ActiveRecord::StatementInvalid # Table doesn't exist yet end def uuid_primary_key? table_name && primary_key && schema_cache.columns_hash(table_name)[primary_key]&.type == :uuid end PendingUuidDefault = Struct.new(:name) do def apply_to(attribute_set) attribute_set[name] = attribute_set[name].with_user_default(-> { ActiveRecord::Type::Uuid.generate }) end end end module MysqlUuidAdapter extend ActiveSupport::Concern # Override lookup_cast_type to recognize binary(16) as UUID type def lookup_cast_type(sql_type) if sql_type == "binary(16)" ActiveRecord::Type.lookup(:uuid, adapter: :trilogy) else super end end # Override fetch_type_metadata to preserve UUID type and limit def fetch_type_metadata(sql_type, extra = "") if sql_type == "binary(16)" simple_type = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( sql_type: sql_type, type: :uuid, limit: 16 ) ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata.new(simple_type, extra: extra) else super end end class_methods do def native_database_types @native_database_types_with_uuid ||= super.merge(uuid: { name: "binary", limit: 16 }) end end end module SqliteUuidAdapter extend ActiveSupport::Concern # Override lookup_cast_type to recognize BLOB as UUID type def lookup_cast_type(sql_type) if sql_type == "blob(16)" ActiveRecord::Type.lookup(:uuid, adapter: :sqlite3) else super end end # Override fetch_type_metadata to preserve UUID type and limit def fetch_type_metadata(sql_type) if sql_type == "blob(16)" ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( sql_type: sql_type, type: :uuid, limit: 16 ) else super end end class_methods do def native_database_types @native_database_types_with_uuid ||= super.merge(uuid: { name: "blob", limit: 16 }) end end end module SchemaDumperUuidType # Map binary(16) and blob(16) columns to :uuid type in schema.rb def schema_type(column) if column.sql_type == "binary(16)" || column.sql_type == "blob(16)" :uuid else super end end end module TableDefinitionUuidSupport def uuid(name, **options) column(name, :uuid, **options) end end ActiveSupport.on_load(:active_record) do ActiveRecord::Base.singleton_class.prepend(UuidPrimaryKeyDefault) ActiveRecord::ConnectionAdapters::TableDefinition.prepend(TableDefinitionUuidSupport) end ActiveSupport.on_load(:active_record_trilogyadapter) do ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(MysqlUuidAdapter) ActiveRecord::ConnectionAdapters::MySQL::SchemaDumper.prepend(SchemaDumperUuidType) end ActiveSupport.on_load(:active_record_sqlite3adapter) do ActiveRecord::ConnectionAdapters::SQLite3Adapter.prepend(SqliteUuidAdapter) ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(SchemaDumperUuidType) end ================================================ FILE: config/initializers/vapid.rb ================================================ Rails.application.configure do config.x.vapid.private_key = ENV["VAPID_PRIVATE_KEY"] config.x.vapid.public_key = ENV["VAPID_PUBLIC_KEY"] end ================================================ FILE: config/initializers/vips.rb ================================================ raise LoadError, "Please install libvips" unless defined?(Vips::LIBRARY_VERSION) # Disable Openslide to prevent sqlite segfault in forked parallel workers # Requires libvips 8.13+ Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block) # Limit libvips to 4 threads for each thread pool. Default is #CPUs. Vips.concurrency_set 4 # Limit libvips caches to reduce memory pressure. # # Do not disable entirely since libvips relies on some caching internally. # (When we disabled caches, we hit a ton of JPEG out of order read errors.) Vips.cache_set_max 10 # Default 100 Vips.cache_set_max_mem 10.megabytes # Default 100MB Vips.cache_set_max_files 10 # Default 100 ================================================ FILE: config/initializers/web_push.rb ================================================ require "web-push" require "web_push/pool" require "web_push/notification" Rails.application.configure do config.x.web_push_pool = WebPush::Pool.new( invalid_subscription_handler: ->(subscription_id) do Rails.application.executor.wrap do Rails.logger.info "Destroying push subscription: #{subscription_id}" Push::Subscription.find_by(id: subscription_id)&.destroy end end ) at_exit { config.x.web_push_pool.shutdown } end module WebPush::PersistentRequest def perform endpoint_ip = @options[:endpoint_ip] if endpoint_ip http = Net::HTTP.new(uri.host, uri.port) http.ipaddr = endpoint_ip http.use_ssl = true http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil? elsif @options[:connection] http = @options[:connection] else http = Net::HTTP.new(uri.host, uri.port, *proxy_options) http.use_ssl = true http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil? end req = Net::HTTP::Post.new(uri.request_uri, headers) req.body = body if http.is_a?(Net::HTTP::Persistent) response = http.request uri, req else resp = http.request(req) verify_response(resp) end resp end end WebPush::Request.prepend WebPush::PersistentRequest ================================================ FILE: config/locales/en.yml ================================================ # Files in the config/locales directory are used for internationalization and # are automatically loaded by Rails. If you want to use locales other than # English, add the necessary files in this directory. # # To use the locales, use `I18n.t`: # # I18n.t "hello" # # In views, this is aliased to just `t`: # # <%= t("hello") %> # # To use a different locale, set it with `I18n.locale`: # # I18n.locale = :es # # This would use the information in config/locales/es.yml. # # To learn more about the API, please read the Rails Internationalization guide # at https://guides.rubyonrails.org/i18n.html. # # Be aware that YAML interprets the following case-insensitive strings as # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings # must be quoted to be interpreted as strings. For example: # # en: # "yes": yup # enabled: "ON" en: hello: "Hello world" ================================================ FILE: config/passkey_aaguids.yml ================================================ shared: apple_passwords: name: Apple Passwords icon: light: passkeys/apple_passwords_light.svg dark: passkeys/apple_passwords_dark.svg aaguids: - dd4ec289-e01d-41c9-bb89-70fa845d4bf2 - fbfc3007-154e-4ecc-8c0b-6e020557d7bd google_password_manager: name: Google Password Manager icon: light: passkeys/google_password_manager_light.svg dark: passkeys/google_password_manager_dark.svg aaguids: - ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4 windows_hello: name: Windows Hello icon: light: passkeys/windows_hello_light.svg dark: passkeys/windows_hello_dark.svg aaguids: - 08987058-cadc-4b81-b6e1-30de50dcbe96 - 6028b017-b1d4-4c02-b4b3-afcdafc96bb2 - 9ddd1817-af5a-4672-a2b9-3e3dd95000a9 1password: name: 1Password icon: light: passkeys/1password_light.svg dark: passkeys/1password_dark.svg aaguids: - bada5566-a7aa-401f-bd96-45619a55120d bitwarden: name: Bitwarden icon: light: passkeys/bitwarden_light.svg dark: passkeys/bitwarden_dark.svg aaguids: - d548826e-79b4-db40-a3d8-11116f7e8349 samsung_pass: name: Samsung Pass icon: light: passkeys/samsung_pass_light.svg dark: passkeys/samsung_pass_dark.svg aaguids: - 53414d53-554e-4700-0000-000000000000 lastpass: name: LastPass icon: light: passkeys/lastpass_light.svg dark: passkeys/lastpass_dark.svg aaguids: - b78a0a55-6ef8-d246-a042-ba0f6d55050c dashlane: name: Dashlane icon: light: passkeys/dashlane_light.svg dark: passkeys/dashlane_dark.svg aaguids: - 531126d6-e717-415c-9320-3d9aa6981239 yubikey: name: YubiKey icon: light: passkeys/yubikey_light.svg dark: passkeys/yubikey_dark.svg aaguids: - 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b - 1ac71f64-468d-4fe0-bef1-0e5f2f551f18 - 20ac7a17-c814-4833-93fe-539f0d5e3389 - 24673149-6c86-42e7-98d9-433fb5b73296 - 2fc0579f-8113-47ea-b116-bb5a8db9202a - 3124e301-f14e-4e38-876d-fbeeb090e7bf - 34744913-4f57-4e6e-a527-e9ec3c4b94e6 - 34f5766d-1536-4a24-9033-0e294e510fb0 - 3a662962-c6d4-4023-bebb-98ae92e78e20 - 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5 - 3b24bf49-1d45-4484-a917-13175df0867b - 4599062e-6926-4fe7-9566-9e8fb1aedaa0 - 4fc84f16-2545-4e53-b8fc-7bf4d7282a10 - 57f7de54-c807-4eab-b1c6-1c9be7984e92 - 58276709-bb4b-4bb3-baf1-60eea99282a7 - 5b0e46ba-db02-44ac-b979-ca9b84f5e335 - 62e54e98-c209-4df3-b692-de71bb6a8528 - 662ef48a-95e2-4aaa-a6c1-5b9c40375824 - 6ab56fad-881f-4a43-acb2-0be065924522 - 6ec5cff2-a0f9-4169-945b-f33b563f7b99 - 73bb0cd4-e502-49b8-9c6f-b59445bf720b - 7409272d-1ff9-4e10-9fc9-ac0019c124fd - 79f3c8ba-9e35-484b-8f47-53a5a0f5c630 - 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432 - 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3 - 83c47309-aabb-4108-8470-8be838b573cb - 85203421-48f9-4355-9bc8-8a53846e5083 - 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee - 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7 - 90636e1f-ef82-43bf-bdcf-5255f139d12f - 97e6a830-c952-4740-95fc-7c78dc97ce47 - 9e66c661-e428-452a-a8fb-51f7ed088acf - 9eb7eabc-9db5-49a1-b6c3-555a802093f4 - a02167b9-ae71-4ac7-9a07-06432ebb6f1c - a25342c0-3cdc-4414-8e46-f4807fca511c - ad08c78a-4e41-49b9-86a2-ac15b06899e2 - b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc - b90e7dc1-316e-4fee-a25a-56a666a670fe - c1f9a0bc-1dd2-404a-b27f-8e29047a43fd - c5ef55ff-ad9a-4b9f-b580-adebafe026d0 - cb69481e-8ff7-4039-93ec-0a2729a154a8 - ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1 - d2fbd093-ee62-488d-9dad-1e36389f8826 - d7781e5d-e353-46aa-afe2-3ca49f13332a - d8522d9f-575b-4866-88a9-ba99fa02f35b - dd86a2da-86a0-4cbe-b462-4bd31f57bc6f - ee882879-721c-4913-9775-3dfcce97072a - fa2b99dc-9e39-4257-8f92-4a30d23c4118 - fcc0118f-cd45-435b-8da1-9782b2da0715 - ff4dac45-ede8-4ec2-aced-cf66103f4335 ================================================ FILE: config/puma.rb ================================================ # Specifies the `port` that Puma will listen on to receive requests; default is 3000. port ENV.fetch("PORT", 3000) # Specifies the `pidfile` that Puma will use. pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid") # Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart # Run Solid Queue with Puma by default. # Disabled when running fizzy-saas or via SOLID_QUEUE_IN_PUMA=false. unless Fizzy.saas? || ENV["SOLID_QUEUE_IN_PUMA"] == "false" plugin :solid_queue end # Expose Prometheus metrics at http://0.0.0.0:9394/metrics (SaaS only). # In dev, overridden to http://127.0.0.1:9306/metrics in .mise.toml. if Fizzy.saas? control_uri = Rails.env.local? ? "unix://tmp/pumactl.sock" : "auto" activate_control_app control_uri, no_token: true plugin :yabeda plugin :yabeda_prometheus end if !Rails.env.local? # Because we expect fewer I/O waits than Rails apps that connect to the # database over the network, let's start with a baseline config of 1 # worker per CPU, 1 thread per worker and tune it from there. # # https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma workers Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) threads 1, 1 # Tell the Ruby VM that we're finished booting up. # # Now's the time to tidy the heap (GC, compact, free empty, malloc_trim, etc) # for optimal copy-on-write efficiency. before_fork do Process.warmup end # Defer major GC (full marking phase) until after request handling, # and perform major GC deferred during request handling. before_worker_boot do GC.config(rgengc_allow_full_mark: false) end out_of_band do GC.start if GC.latest_gc_info(:need_major_by) end end ================================================ FILE: config/queue.yml ================================================ default: &default dispatchers: - polling_interval: 1 batch_size: 500 workers: - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "*" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 development: *default test: *default beta: *default staging: *default production: *default ================================================ FILE: config/recurring.yml ================================================ <% require_relative "../lib/fizzy" %> production: &production # Application functionality: notifications and summaries deliver_bundled_notifications: command: "Notification::Bundle.deliver_all_later" schedule: every 30 minutes # Application cleanup auto_postpone_all_due: command: "Card.auto_postpone_all_due" schedule: every hour at minute 50 delete_unused_tags: class: DeleteUnusedTagsJob schedule: every day at 04:02 # Operations cleanup and backups clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 cleanup_webhook_deliveries: command: "Webhook::Delivery.cleanup" schedule: every 15 minutes cleanup_magic_links: command: "MagicLink.cleanup" schedule: every 4 hours cleanup_exports: command: "Export.cleanup" schedule: every hour at minute 20 cleanup_imports: command: "Account::Import.cleanup" schedule: every hour at minute 25 incineration: class: "Account::IncinerateDueJob" schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> # Metrics yabeda_actioncable: command: "Yabeda::ActionCable.measure" schedule: every 60 seconds <% end %> beta: &beta # Only Solid Queue maintenance clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 staging: *beta development: *production ================================================ FILE: config/routes.rb ================================================ Rails.application.routes.draw do root "events#index" namespace :account do resource :cancellation, only: [ :create ] resource :entropy resource :join_code resource :settings resources :exports, only: [ :create, :show ] resources :imports, only: [ :new, :create, :show ] end resources :users do scope module: :users do resource :avatar resource :role resource :events resources :push_subscriptions resources :email_addresses, param: :token do resource :confirmation, module: :email_addresses end resources :data_exports, only: [ :create, :show ] end end resources :boards do scope module: :boards do resource :subscriptions resource :involvement resource :publication resource :entropy namespace :columns do resource :not_now resource :stream resource :closed end resources :columns end resources :cards, only: :create resources :webhooks do scope module: :webhooks do resource :activation, only: :create end end end resources :columns, only: [] do resource :left_position, module: :columns resource :right_position, module: :columns end namespace :columns do resources :cards do scope module: :cards do namespace :drops do resource :not_now resource :stream resource :closure resource :column end end end end namespace :cards do resources :previews end resources :cards do scope module: :cards do resource :draft, only: :show resource :board resource :closure resource :column resource :goldness resource :image resource :not_now resource :pin resource :publish resource :reading resource :triage resource :watch resource :reading resources :reactions resources :assignments resource :self_assignment, only: :create resources :steps resources :taggings resources :comments do resources :reactions, module: :comments end end end resources :tags, only: :index namespace :notifications do resource :settings resource :unsubscribe end resources :notifications do scope module: :notifications do get "tray", to: "trays#show", on: :collection resource :reading collection do resource :bulk_reading, only: :create end end end resource :search namespace :searches do resources :queries end resources :filters do scope module: :filters do collection do resource :settings_refresh, only: :create end end end resources :events, only: :index namespace :events do resources :days namespace :day_timeline do resources :columns, only: :show end end resources :qr_codes get "join/:code", to: "join_codes#new", as: :join post "join/:code", to: "join_codes#create" namespace :users do resources :joins resources :verifications, only: %i[ new create ] end resource :session do scope module: :sessions do resources :transfers resource :magic_link resource :menu resource :passkey, only: :create end end get "/signup", to: redirect("/signup/new") resource :signup, only: %i[ new create ] do collection do scope module: :signups, as: :signup do resource :completion, only: %i[ new create ] end end end resource :landing namespace :my do resource :passkey_challenge, only: :create resource :identity, only: :show resources :access_tokens resources :passkeys, except: %i[ show new ] resources :pins resource :timezone resource :menu end namespace :prompts do resources :cards resources :tags resources :users resources :boards do scope module: :boards do resources :users end end end namespace :public do resources :boards do scope module: :boards do namespace :columns do resource :not_now, only: :show resource :stream, only: :show resource :closed, only: :show end resources :columns, only: :show end resources :cards, only: :show end end direct :published_board do |board, options| route_for :public_board, board.publication.key end direct :published_card do |card, options| route_for :public_board_card, card.board.publication.key, card end resolve "Comment" do |comment, options| options[:anchor] = ActionView::RecordIdentifier.dom_id(comment) route_for :card, comment.card, options end resolve "Mention" do |mention, options| polymorphic_url(mention.source, options) end resolve "Notification" do |notification, options| polymorphic_url(notification.notifiable_target, options) end resolve "Event" do |event, options| polymorphic_url(event.eventable, options) end resolve "Webhook" do |webhook, options| route_for :board_webhook, webhook.board, webhook, options end # Support for legacy URLs get "/collections/:collection_id/cards/:id", to: redirect { |params, request| "#{request.script_name}/cards/#{params[:id]}" } get "/collections/:id", to: redirect { |params, request| "#{request.script_name}/boards/#{params[:id]}" } get "/public/collections/:id", to: redirect { |params, request| "#{request.script_name}/public/boards/#{params[:id]}" } get "up", to: "rails/health#show", as: :rails_health_check get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" # Mobile clients get "client_configurations/(:platform)_v(:version)" => "client_configurations#show", platform: /android|ios/, version: /\d+/ namespace :admin do mount MissionControl::Jobs::Engine, at: "/jobs" end end ================================================ FILE: config/storage.oss.yml ================================================ test: service: Disk root: <%= Rails.root.join("tmp/storage/files") %> local: service: Disk root: <%= Rails.root.join("storage", Rails.env, "files") %> devminio: service: S3 bucket: fizzy-dev-activestorage endpoint: "http://minio.localhost:39000" force_path_style: true request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support region: us-east-1 # default region required for signer access_key_id: minioadmin secret_access_key: minioadmin s3: service: S3 access_key_id: <%= ENV["S3_ACCESS_KEY_ID"] %> bucket: <%= ENV["S3_BUCKET"] || "fizzy-#{Rails.env}-activestorage" %> endpoint: <%= ENV["S3_ENDPOINT"] %> force_path_style: <%= ENV["S3_FORCE_PATH_STYLE"] == "true" %> region: <%= ENV.fetch("S3_REGION", "us-east-1") %> request_checksum_calculation: <%= ENV.fetch("S3_REQUEST_CHECKSUM_CALCULATION", "when_supported") %> response_checksum_validation: <%= ENV.fetch("S3_RESPONSE_CHECKSUM_VALIDATION", "when_supported") %> secret_access_key: <%= ENV["S3_SECRET_ACCESS_KEY"] %> ================================================ FILE: config/storage.yml ================================================ <% config_path = if Fizzy.saas? gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir File.join(gem_path, "config", "storage.yml") else File.join(__dir__, "storage.oss.yml") end %> <%= ERB.new(File.read(config_path)).result %> ================================================ FILE: config.ru ================================================ # This file is used by Rack-based servers to start the application. require_relative 'config/environment' use Autotuner::RackPlugin run Rails.application Rails.application.load_server ================================================ FILE: db/cable_schema.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 1) do create_table "solid_cable_messages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.binary "channel", limit: 1024, null: false t.bigint "channel_hash", null: false t.datetime "created_at", null: false t.binary "payload", size: :long, null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" end end ================================================ FILE: db/cache_schema.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 1) do create_table "solid_cache_entries", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "byte_size", null: false t.datetime "created_at", null: false t.binary "key", limit: 1024, null: false t.bigint "key_hash", null: false t.binary "value", size: :long, null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true end end ================================================ FILE: db/migrate/20251111122540_initial_schema.rb ================================================ class InitialSchema < ActiveRecord::Migration[8.2] def change create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "accessed_at" t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "involvement", limit: 255, default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["accessed_at"], name: "index_accesses_on_accessed_at", order: :desc t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true t.index ["board_id"], name: "index_accesses_on_board_id" t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "usage_count", default: 0, null: false t.integer "usage_limit", default: 10, null: false t.index ["code"], name: "index_account_join_codes_on_code", unique: true end create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.integer "external_account_id" t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.text "body", size: :long t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.uuid "record_id", null: false t.string "record_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "blob_id", null: false t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.uuid "record_id", null: false t.string "record_type", limit: 255, null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.bigint "byte_size", null: false t.string "checksum", limit: 255 t.string "content_type", limit: 255 t.datetime "created_at", null: false t.string "filename", limit: 255, null: false t.string "key", limit: 255, null: false t.text "metadata" t.string "service_name", limit: 255, null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "blob_id", null: false t.string "variation_digest", limit: 255, null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end create_table "assignees_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "assignee_id", null: false t.uuid "filter_id", null: false t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" end create_table "assigners_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "assigner_id", null: false t.uuid "filter_id", null: false t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" end create_table "assignments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "assignee_id", null: false t.uuid "assigner_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true t.index ["card_id"], name: "index_assignments_on_card_id" end create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "key", limit: 255 t.datetime "updated_at", null: false t.index ["board_id"], name: "index_board_publications_on_board_id" t.index ["key"], name: "index_board_publications_on_key", unique: true end create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.boolean "all_access", default: false, null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["creator_id"], name: "index_boards_on_creator_id" end create_table "boards_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "board_id", null: false t.uuid "filter_id", null: false t.index ["board_id"], name: "index_boards_filters_on_board_id" t.index ["filter_id"], name: "index_boards_filters_on_filter_id" end create_table "card_activity_spikes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["card_id"], name: "index_card_activity_spikes_on_card_id" end create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id" t.datetime "created_at", null: false t.string "status", limit: 255, default: "doing", null: false t.datetime "updated_at", null: false t.index ["card_id"], name: "index_card_engagements_on_card_id" t.index ["status"], name: "index_card_engagements_on_status" end create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true end create_table "card_not_nows", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true t.index ["user_id"], name: "index_card_not_nows_on_user_id" end create_table "cards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.uuid "board_id", null: false t.uuid "column_id" t.datetime "created_at", null: false t.uuid "creator_id", null: false t.date "due_on" t.datetime "last_active_at", null: false t.string "status", limit: 255, default: "drafted", null: false t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["board_id"], name: "index_cards_on_board_id" t.index ["column_id"], name: "index_cards_on_column_id" t.index ["last_active_at", "status"], name: "index_cards_on_last_active_at_and_status" end create_table "closers_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "closer_id", null: false t.uuid "filter_id", null: false t.index ["closer_id"], name: "index_closers_filters_on_closer_id" t.index ["filter_id"], name: "index_closers_filters_on_filter_id" end create_table "closures", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" t.index ["card_id"], name: "index_closures_on_card_id", unique: true t.index ["user_id"], name: "index_closures_on_user_id" end create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.uuid "board_id", null: false t.string "color", limit: 255, null: false t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" t.index ["board_id"], name: "index_columns_on_board_id" end create_table "comments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.datetime "updated_at", null: false t.index ["card_id"], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "creator_id", null: false t.uuid "filter_id", null: false t.index ["creator_id"], name: "index_creators_filters_on_creator_id" t.index ["filter_id"], name: "index_creators_filters_on_filter_id" end create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.bigint "auto_postpone_period", default: 2592000, null: false t.uuid "container_id", null: false t.string "container_type", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true end create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.string "action", limit: 255, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.uuid "eventable_id", null: false t.string "eventable_type", limit: 255, null: false t.json "particulars", default: -> { "(json_object())" } t.datetime "updated_at", null: false t.index ["action"], name: "index_events_on_summary_id_and_action" t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" t.index ["board_id"], name: "index_events_on_board_id" t.index ["creator_id"], name: "index_events_on_creator_id" t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.datetime "created_at", null: false t.uuid "creator_id", null: false t.json "fields", default: -> { "(json_object())" }, null: false t.string "params_digest", limit: 255, null: false t.datetime "updated_at", null: false t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true end create_table "filters_tags", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "filter_id", null: false t.uuid "tag_id", null: false t.index ["filter_id"], name: "index_filters_tags_on_filter_id" t.index ["tag_id"], name: "index_filters_tags_on_tag_id" end create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.string "email_address", limit: 255, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_identities_on_email_address", unique: true end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.uuid "identity_id" t.datetime "updated_at", null: false t.index ["code"], name: "index_magic_links_on_code", unique: true t.index ["expires_at"], name: "index_magic_links_on_expires_at" t.index ["identity_id"], name: "index_magic_links_on_identity_id" end create_table "memberships", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false t.string "join_code", limit: 255 t.string "tenant", limit: 255, null: false t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_memberships_on_identity_id" t.index ["tenant", "identity_id"], name: "index_memberships_on_tenant_and_identity_id", unique: true t.index ["tenant"], name: "index_memberships_on_user_tenant_and_user_id" end create_table "mentions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "mentionee_id", null: false t.uuid "mentioner_id", null: false t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" t.index ["source_type", "source_id"], name: "index_mentions_on_source" end create_table "notification_bundles", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.datetime "created_at", null: false t.datetime "ends_at", null: false t.datetime "starts_at", null: false t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" end create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true t.index ["card_id"], name: "index_pins_on_card_id" t.index ["user_id"], name: "index_pins_on_user_id" end create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.string "auth_key", limit: 255 t.datetime "created_at", null: false t.text "endpoint" t.string "p256dh_key", limit: 255 t.datetime "updated_at", null: false t.string "user_agent", limit: 255 t.uuid "user_id", null: false t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576" t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint" t.index ["user_agent"], name: "index_push_subscriptions_on_user_agent" t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true t.index ["user_id"], name: "index_push_subscriptions_on_user_id" end create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "comment_id", null: false t.string "content", limit: 16, null: false t.datetime "created_at", null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false t.index ["comment_id"], name: "index_reactions_on_comment_id" t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end create_table "search_queries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.string "terms", limit: 2000, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true t.index ["user_id"], name: "index_search_queries_on_user_id" end create_table "search_results", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false t.string "ip_address", limit: 255 t.datetime "updated_at", null: false t.string "user_agent", limit: 255 t.index ["identity_id"], name: "index_sessions_on_identity_id" end create_table "steps", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.uuid "card_id", null: false t.boolean "completed", default: false, null: false t.text "content", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" t.index ["card_id"], name: "index_steps_on_card_id" end create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "tag_id", null: false t.datetime "updated_at", null: false t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" end create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.datetime "created_at", null: false t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["title"], name: "index_tags_on_title", unique: true end create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.integer "bundle_email_frequency", default: 0, null: false t.datetime "created_at", null: false t.string "timezone_name", limit: 255 t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" t.index ["user_id"], name: "index_user_settings_on_user_id" end create_table "users", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.uuid "membership_id" t.string "name", limit: 255, null: false t.string "role", limit: 255, default: "member", null: false t.datetime "updated_at", null: false t.index ["membership_id"], name: "index_users_on_membership_id" t.index ["role"], name: "index_users_on_role" end create_table "watches", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.boolean "watching", default: true, null: false t.index ["card_id"], name: "index_watches_on_card_id" t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" t.index ["user_id"], name: "index_watches_on_user_id" end create_table "webhook_delinquency_trackers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.integer "consecutive_failures_count", default: 0 t.datetime "created_at", null: false t.datetime "first_failure_at" t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" end create_table "webhook_deliveries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.datetime "created_at", null: false t.uuid "event_id", null: false t.text "request" t.text "response" t.string "state", limit: 255, null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end create_table "webhooks", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.uuid "account_id" t.boolean "active", default: true, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "name", limit: 255 t.string "signing_secret", limit: 255, null: false t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false t.index ["board_id"], name: "index_webhooks_on_board_id" t.index ["subscribed_actions"], name: "index_webhooks_on_subscribed_actions", length: 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 "memberships", "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 "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 end ================================================ FILE: db/migrate/20251111153019_add_number_to_cards.rb ================================================ class AddNumberToCards < ActiveRecord::Migration[8.2] def change add_column :cards, :number, :bigint, null: false add_column :accounts, :cards_count, :bigint, default: 0, null: false add_index :cards, [:account_id, :number], unique: true end end ================================================ FILE: db/migrate/20251112093037_create_search_indices.rb ================================================ class CreateSearchIndices < ActiveRecord::Migration[8.2] def up # Skip for SQLite - it doesn't use these tables return if connection.adapter_name == "SQLite" 16.times do |i| create_table "search_index_#{i}".to_sym, id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t| t.string :searchable_type, null: false t.uuid :searchable_id, null: false t.uuid :card_id, null: false t.uuid :board_id, null: false t.string :title t.text :content t.datetime :created_at, null: false t.index [:searchable_type, :searchable_id], unique: true, name: "idx_si#{i}_type_id" t.index [:content, :title], type: :fulltext, name: "idx_si#{i}_fulltext" end end end def down # Skip for SQLite - it doesn't use these tables return if connection.adapter_name == "SQLite" 16.times do |i| drop_table "search_index_#{i}".to_sym end end end ================================================ FILE: db/migrate/20251112184932_remove_join_code_from_memberships.rb ================================================ class RemoveJoinCodeFromMemberships < ActiveRecord::Migration[8.2] def change remove_column :memberships, :join_code, :string end end ================================================ FILE: db/migrate/20251113111501_drop_memberships.rb ================================================ class DropMemberships < ActiveRecord::Migration[8.2] def change add_reference :users, :identity, type: :uuid, null: true, foreign_key: true remove_column :users, :membership_id, :bigint drop_table :memberships end end ================================================ FILE: db/migrate/20251113160907_add_missing_account_id_columns.rb ================================================ class AddMissingAccountIdColumns < ActiveRecord::Migration[8.2] MISSING_TABLES= %w[ accesses assignments board_publications card_activity_spikes card_engagements card_goldnesses card_not_nows closures entropies mentions pins reactions search_queries taggings user_settings watches webhook_delinquency_trackers webhook_deliveries action_text_rich_texts active_storage_attachments active_storage_blobs active_storage_variant_records ] NOT_REQUIRED_TABLES = %w[ account_join_codes boards cards columns comments events filters notification_bundles notifications push_subscriptions steps tags users webhooks ] def change MISSING_TABLES.each do |table| add_column table, "account_id", :uuid, null: false end NOT_REQUIRED_TABLES.each do |table| change_column table, "account_id", :uuid, null: false end end end ================================================ FILE: db/migrate/20251113163145_ensure_account_id_index.rb ================================================ class EnsureAccountIdIndex < ActiveRecord::Migration[8.2] def change remove_index :accesses, :accessed_at add_index :accesses, [:account_id, :accessed_at] remove_index :account_join_codes, :code add_index :account_join_codes, [:account_id, :code], unique: true add_index :assignments, :account_id remove_index :board_publications, :key add_index :board_publications, [:account_id, :key] add_index :boards, :account_id add_index :card_activity_spikes, :account_id remove_index :card_engagements, :status add_index :card_engagements, [:account_id, :status] add_index :card_goldnesses, :account_id add_index :card_not_nows, :account_id remove_index :cards, [:last_active_at, :status] add_index :cards, [:account_id, :last_active_at, :status] add_index :closures, :account_id add_index :columns, :account_id add_index :comments, :account_id add_index :entropies, :account_id remove_index :events, :action add_index :events, [:account_id, :action] add_index :filters, :account_id add_index :mentions, :account_id add_index :notification_bundles, :account_id add_index :notifications, :account_id add_index :pins, :account_id add_index :push_subscriptions, :account_id remove_index :push_subscriptions, :endpoint # duplicative because we always query [user_id, endpoint] remove_index :push_subscriptions, [:endpoint, :p256dh_key, :auth_key] # duplicative and not necessary remove_index :push_subscriptions, :user_agent # not necessary remove_index :push_subscriptions, :user_id # duplicative of [user_id, endpoint] add_index :reactions, :account_id add_index :search_queries, :account_id add_index :steps, :account_id add_index :taggings, :account_id remove_index :tags, :title add_index :tags, [:account_id, :title], unique: true add_index :user_settings, :account_id remove_index :users, :role add_index :users, [:account_id, :role] add_index :watches, :account_id add_index :webhook_delinquency_trackers, :account_id add_index :webhook_deliveries, :account_id # For webhooks, I'm making an additional change to collapse board_id and subscribed_actions into # a single index, since Triggerable only queries for `subscribed_actions` in conjunction with # `board_id`. add_index :webhooks, :account_id remove_index :webhooks, :subscribed_actions add_index :webhooks, [:board_id, :subscribed_actions], length: { subscribed_actions: 255 } remove_index :webhooks, :board_id # Rails models add_index :action_text_rich_texts, :account_id add_index :active_storage_attachments, :account_id add_index :active_storage_blobs, :account_id add_index :active_storage_variant_records, :account_id end end ================================================ FILE: db/migrate/20251113190256_create_search_record_shards.rb ================================================ class CreateSearchRecordShards < ActiveRecord::Migration[8.2] SHARD_COUNT = 16 def change # Skip for SQLite - it uses a single search_records table instead return if connection.adapter_name == "SQLite" # Create 16 sharded search_records tables SHARD_COUNT.times do |shard_id| create_table "search_records_#{shard_id}", id: :uuid do |t| t.uuid :account_id, null: false t.string :searchable_type, null: false t.uuid :searchable_id, null: false t.uuid :card_id, null: false t.uuid :board_id, null: false t.string :title t.text :content t.datetime :created_at, null: false t.index [:searchable_type, :searchable_id], unique: true t.index :account_id t.index [:content, :title], type: :fulltext end end # Drop old search_index tables SHARD_COUNT.times do |shard_id| drop_table "search_index_#{shard_id}", if_exists: true do |t| t.string :searchable_type, null: false t.uuid :searchable_id, null: false t.uuid :card_id, null: false t.uuid :board_id, null: false t.string :title t.text :content t.datetime :created_at, null: false t.index [:searchable_type, :searchable_id], unique: true, name: "idx_si#{shard_id}_type_id" t.index [:content, :title], type: :fulltext, name: "idx_si#{shard_id}_fulltext" end end end end ================================================ FILE: db/migrate/20251114084325_drop_search_results.rb ================================================ class DropSearchResults < ActiveRecord::Migration[8.2] def change drop_table :search_results do |t| t.timestamps end end end ================================================ FILE: db/migrate/20251114183203_ensure_an_identit_can_only_have_one_user_in_an_account.rb ================================================ class EnsureAnIdentitCanOnlyHaveOneUserInAnAccount < ActiveRecord::Migration[8.2] def change add_index :users, [:account_id, :identity_id], unique: true end end ================================================ FILE: db/migrate/20251117190817_change_endpoint_to_text_in_push_subscriptions.rb ================================================ class ChangeEndpointToTextInPushSubscriptions < ActiveRecord::Migration[8.2] def change # Remove foreign key first, then the index remove_foreign_key :push_subscriptions, :users remove_index :push_subscriptions, column: [:user_id, :endpoint] # Change the column type change_column :push_subscriptions, :endpoint, :text # Re-add the index and foreign key add_index :push_subscriptions, [:user_id, :endpoint], unique: true, length: { endpoint: 255 } add_foreign_key :push_subscriptions, :users end end ================================================ FILE: db/migrate/20251117192434_change_external_account_id_to_bigint_in_accounts.rb ================================================ class ChangeExternalAccountIdToBigintInAccounts < ActiveRecord::Migration[8.2] def change change_column :accounts, :external_account_id, :bigint end end ================================================ FILE: db/migrate/20251117202517_change_usage_limit_to_bigint_in_account_join_codes.rb ================================================ class ChangeUsageLimitToBigintInAccountJoinCodes < ActiveRecord::Migration[8.2] def change change_column :account_join_codes, :usage_count, :bigint change_column :account_join_codes, :usage_limit, :bigint end end ================================================ FILE: db/migrate/20251120110206_add_search_records.rb ================================================ class AddSearchRecords < ActiveRecord::Migration[8.2] def up return unless connection.adapter_name == "SQLite" # Create regular table with integer primary key for FTS5 rowid compatibility create_table :search_records do |t| t.uuid :account_id, null: false t.string :searchable_type, limit: 255, null: false t.uuid :searchable_id, null: false t.uuid :card_id, null: false t.uuid :board_id, null: false t.string :title, limit: 255 t.text :content t.datetime :created_at, null: false t.index [:searchable_type, :searchable_id], unique: true t.index :account_id end # Create FTS5 virtual table using Porter stemmer # No triggers needed - Searchable concern handles sync via callbacks execute <<-SQL CREATE VIRTUAL TABLE search_records_fts USING fts5( title, content, tokenize='porter' ) SQL end def down return unless connection.adapter_name == "SQLite" execute "DROP TABLE IF EXISTS search_records_fts" drop_table :search_records, if_exists: true end end ================================================ FILE: db/migrate/20251120194700_remove_all_foreign_key_constraints.rb ================================================ 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 ================================================ FILE: db/migrate/20251120203100_add_unique_index_to_card_activity_spikes_on_card_id.rb ================================================ class AddUniqueIndexToCardActivitySpikesOnCardId < ActiveRecord::Migration[8.2] def change if ActiveRecord::Base.connection.adapter_name != "SQLite" 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 end remove_index :card_activity_spikes, :card_id add_index :card_activity_spikes, :card_id, unique: true end end ================================================ FILE: db/migrate/20251121092508_add_account_key_to_search_records.rb ================================================ class AddAccountKeyToSearchRecords < ActiveRecord::Migration[8.2] def up return if ActiveRecord::Base.connection.adapter_name == "SQLite" 16.times do |shard_id| table_name = "search_records_#{shard_id}" add_column table_name, :account_key, :string, null: false, default: "" add_index table_name, [:account_key, :content, :title], type: :fulltext end end def down return if ActiveRecord::Base.connection.adapter_name == "SQLite" 16.times do |shard_id| table_name = "search_records_#{shard_id}" remove_index table_name, column: [:account_key, :content, :title], type: :fulltext remove_column table_name, :account_key end end end ================================================ FILE: db/migrate/20251121112416_remove_old_fulltext_indexes_from_search_records.rb ================================================ class RemoveOldFulltextIndexesFromSearchRecords < ActiveRecord::Migration[8.2] def up return if ActiveRecord::Base.connection.adapter_name == "SQLite" (0..15).each do |shard| remove_index "search_records_#{shard}", name: "index_search_records_#{shard}_on_content_and_title" end end def down return if ActiveRecord::Base.connection.adapter_name == "SQLite" (0..15).each do |shard| add_index "search_records_#{shard}", [ :content, :title ], type: :fulltext, name: "index_search_records_#{shard}_on_content_and_title" end end end ================================================ FILE: db/migrate/20251125110629_increase_user_agent_length.rb ================================================ class IncreaseUserAgentLength < ActiveRecord::Migration[8.2] def change change_column :sessions, :user_agent, :string, limit: 4096 change_column :push_subscriptions, :user_agent, :string, limit: 4096 end end ================================================ FILE: db/migrate/20251125130010_add_a_staff_flag_to_identities.rb ================================================ class AddAStaffFlagToIdentities < ActiveRecord::Migration[8.2] def change add_column :identities, :staff, :boolean, null: false, default: false end end ================================================ FILE: db/migrate/20251127000001_create_account_external_id_sequences.rb ================================================ class CreateAccountExternalIdSequences < ActiveRecord::Migration[8.0] def change create_table :account_external_id_sequences, id: :uuid do |t| t.bigint :value, null: false, default: 0 t.index :value, unique: true end end end ================================================ FILE: db/migrate/20251129110120_add_purpose_to_magic_links.rb ================================================ class AddPurposeToMagicLinks < ActiveRecord::Migration[8.2] def change add_column :magic_links, :purpose, :integer, null: true execute <<-SQL UPDATE magic_links SET purpose = 0 SQL change_column_null :magic_links, :purpose, false end end ================================================ FILE: db/migrate/20251129175717_promote_first_admin_to_owner.rb ================================================ class PromoteFirstAdminToOwner < ActiveRecord::Migration[8.2] def up Account.find_each do |account| next if account.users.exists?(role: :owner) first_admin = account.users.where(role: :admin).order(:created_at).first first_admin&.update!(role: :owner) end end def down User.where(role: :owner).update_all(role: :admin) end end ================================================ FILE: db/migrate/20251201100607_create_account_exports.rb ================================================ class CreateAccountExports < ActiveRecord::Migration[8.2] def change create_table :account_exports, id: :uuid do |t| t.uuid :account_id, null: false t.uuid :user_id, null: false t.string :status, default: "pending", null: false t.datetime :completed_at t.timestamps t.index :account_id t.index :user_id end end end ================================================ FILE: db/migrate/20251201132341_create_identity_access_tokens.rb ================================================ class CreateIdentityAccessTokens < ActiveRecord::Migration[8.2] def change create_table :identity_access_tokens, id: :uuid do |t| t.uuid :identity_id, null: false t.string :token t.string :permission t.text :description t.timestamps t.index ["identity_id"], name: "index_access_token_on_identity_id" end end end ================================================ FILE: db/migrate/20251205010536_add_verified_at_to_users.rb ================================================ class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2] def change add_column :users, :verified_at, :datetime end end ================================================ FILE: db/migrate/20251205205826_create_storage_tables.rb ================================================ class CreateStorageTables < ActiveRecord::Migration[8.0] def change # Storage ledger: debit/credit event stream create_table :storage_entries, id: :uuid do |t| t.references :account, type: :uuid, null: false t.references :board, type: :uuid, null: true t.references :recordable, type: :uuid, polymorphic: true, null: true t.bigint :delta, null: false t.string :operation, null: false t.datetime :created_at, null: false end # Storage totals: cached snapshots create_table :storage_totals, id: :uuid do |t| t.references :owner, type: :uuid, polymorphic: true, null: false, index: false t.bigint :bytes_stored, null: false, default: 0 t.uuid :last_entry_id # Cursor: includes all entries <= this ID t.timestamps t.index %i[ owner_type owner_id ], unique: true end end end ================================================ FILE: db/migrate/20251210054934_add_blob_id_and_audit_context_to_storage_entries.rb ================================================ class AddBlobIdAndAuditContextToStorageEntries < ActiveRecord::Migration[8.2] def change change_table :storage_entries do |t| t.references :blob, type: :uuid, foreign_key: false, index: true t.references :user, type: :uuid, foreign_key: false, index: true t.string :request_id, index: true end end end ================================================ FILE: db/migrate/20251219120755_drop_card_engagements.rb ================================================ class DropCardEngagements < ActiveRecord::Migration[8.2] def up drop_table :card_engagements end def down create_table :card_engagements, id: :uuid do |t| t.references :account, type: :uuid, null: false t.references :card, type: :uuid, null: false, index: true t.string :status, null: false t.timestamps end add_index :card_engagements, [ :account_id, :status ] end end ================================================ FILE: db/migrate/20251223000001_rename_account_exports_to_exports.rb ================================================ class RenameAccountExportsToExports < ActiveRecord::Migration[8.2] def change rename_table :account_exports, :exports add_column :exports, :type, :string add_index :exports, :type end end ================================================ FILE: db/migrate/20251223000002_create_account_imports.rb ================================================ class CreateAccountImports < ActiveRecord::Migration[8.2] def change create_table :account_imports, id: :uuid do |t| t.uuid :identity_id, null: false t.uuid :account_id t.string :status, default: "pending", null: false t.datetime :completed_at t.timestamps t.index :identity_id t.index :account_id end end end ================================================ FILE: db/migrate/20251224092315_create_account_cancellations.rb ================================================ class CreateAccountCancellations < ActiveRecord::Migration[8.2] def change create_table :account_cancellations, id: :uuid do |t| t.uuid :account_id, null: false, index: { unique: true } t.uuid :initiated_by_id, null: false t.timestamps end end end ================================================ FILE: db/migrate/20260121155752_make_reactions_polymorphic.rb ================================================ class MakeReactionsPolymorphic < ActiveRecord::Migration[8.0] def change add_column :reactions, :reactable_type, :string add_column :reactions, :reactable_id, :uuid reversible do |dir| dir.up do execute <<~SQL UPDATE reactions SET reactable_type = 'Comment', reactable_id = comment_id SQL end dir.down do execute <<~SQL UPDATE reactions SET comment_id = reactable_id WHERE reactable_type = 'Comment' SQL end end change_column_null :reactions, :reactable_type, false change_column_null :reactions, :reactable_id, false remove_column :reactions, :comment_id, :uuid add_index :reactions, [:reactable_type, :reactable_id] end end ================================================ FILE: db/migrate/20260206104338_add_card_id_to_notifications.rb ================================================ class AddCardIdToNotifications < ActiveRecord::Migration[8.2] def change add_column :notifications, :card_id, :uuid add_column :notifications, :unread_count, :integer, null: false, default: 0 end end ================================================ FILE: db/migrate/20260209165805_notifications_data_migration.rb ================================================ class NotificationsDataMigration < ActiveRecord::Migration[8.2] BATCH_SIZE = 10_000 class Notification < ActiveRecord::Base self.table_name = "notifications" end def change reversible do |dir| dir.up do populate_card_id collapse_duplicates end end change_column_null :notifications, :card_id, false add_index :notifications, [ :user_id, :card_id ], unique: true end private def populate_card_id execute(<<~SQL) UPDATE notifications SET card_id = ( SELECT CASE events.eventable_type WHEN 'Card' THEN events.eventable_id WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id) END FROM events WHERE events.id = notifications.source_id ) WHERE notifications.card_id IS NULL AND notifications.source_type = 'Event' SQL execute(<<~SQL) UPDATE notifications SET card_id = ( SELECT CASE mentions.source_type WHEN 'Card' THEN mentions.source_id WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id) END FROM mentions WHERE mentions.id = notifications.source_id ) WHERE notifications.card_id IS NULL AND notifications.source_type = 'Mention' SQL end def collapse_duplicates loop do duplicates = Notification.find_by_sql(<<~SQL) SELECT user_id, card_id, MAX(id) AS keep_id, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total FROM notifications WHERE card_id IS NOT NULL GROUP BY user_id, card_id HAVING COUNT(*) > 1 LIMIT #{BATCH_SIZE} SQL break if duplicates.empty? duplicates.each do |row| Notification.where(user_id: row.user_id, card_id: row.card_id) .where.not(id: row.keep_id) .delete_all Notification.where(id: row.keep_id) .update_all(unread_count: row.unread_total.to_i) end end # Set unread_count for remaining non-collapsed notifications execute(<<~SQL) UPDATE notifications SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END WHERE unread_count = 0 AND card_id IS NOT NULL SQL end end ================================================ FILE: db/migrate/20260211122517_add_failure_reason_to_account_imports.rb ================================================ class AddFailureReasonToAccountImports < ActiveRecord::Migration[8.2] def change add_column :account_imports, :failure_reason, :string end end ================================================ FILE: db/migrate/20260212102026_fix_notifications_ordered_index.rb ================================================ class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2] def change add_index :notifications, [ :user_id, :read_at, :updated_at ], order: { read_at: :desc, updated_at: :desc }, name: "index_notifications_on_user_id_and_read_at_and_updated_at" remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at" end end ================================================ FILE: db/migrate/20260213154740_create_action_pack_passkeys.rb ================================================ class CreateActionPackPasskeys < ActiveRecord::Migration[8.2] def change create_table :action_pack_passkeys, id: :uuid do |t| t.uuid :holder_id, null: false t.string :holder_type, null: false t.string :credential_id, null: false t.binary :public_key, null: false t.integer :sign_count, null: false, default: 0 t.string :name t.text :transports t.string :aaguid t.boolean :backed_up t.timestamps t.index [ :holder_type, :holder_id ] t.index :credential_id, unique: true end end end ================================================ FILE: db/migrate/20260213170100_add_created_at_index_to_webhook_deliveries.rb ================================================ class AddCreatedAtIndexToWebhookDeliveries < ActiveRecord::Migration[8.2] def change add_index :webhook_deliveries, :created_at end end ================================================ FILE: db/migrate/20260218120000_restore_unique_index_on_board_publication_key.rb ================================================ class RestoreUniqueIndexOnBoardPublicationKey < ActiveRecord::Migration[8.2] def change add_index :board_publications, :key, unique: true add_index :board_publications, :account_id remove_index :board_publications, [:account_id, :key] end end ================================================ FILE: db/queue_schema.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 1) do create_table "solid_queue_blocked_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "concurrency_key", null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.bigint "job_id", null: false t.integer "priority", default: 0, null: false t.string "queue_name", null: false t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.text "error" t.bigint "job_id", null: false t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "active_job_id" t.text "arguments" t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false t.datetime "finished_at" t.integer "priority", default: 0, null: false t.string "queue_name", null: false t.datetime "scheduled_at" t.datetime "updated_at", null: false t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "queue_name", null: false t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false t.text "metadata" t.string "name", null: false t.integer "pid", null: false t.bigint "supervisor_id" t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "job_id", null: false t.integer "priority", default: 0, null: false t.string "queue_name", null: false t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "job_id", null: false t.datetime "run_at", null: false t.string "task_key", null: false t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.text "arguments" t.string "class_name" t.string "command", limit: 2048 t.datetime "created_at", null: false t.text "description" t.string "key", null: false t.integer "priority", default: 0 t.string "queue_name" t.string "schedule", null: false t.boolean "static", default: true, null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "job_id", null: false t.integer "priority", default: 0, null: false t.string "queue_name", null: false t.datetime "scheduled_at", null: false t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "expires_at", null: false t.string "key", null: false t.datetime "updated_at", null: false t.integer "value", default: 1, null: false t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade end ================================================ FILE: db/schema.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) 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 t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "involvement", default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true t.index ["board_id"], name: "index_accesses_on_board_id" t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "account_cancellations", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "initiated_by_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true end create_table "account_external_id_sequences", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true end create_table "account_imports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_imports_on_account_id" t.index ["identity_id"], name: "index_account_imports_on_identity_id" end create_table "account_join_codes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "code", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "usage_count", default: 0, null: false t.bigint "usage_limit", default: 10, null: false t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true end create_table "accounts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "cards_count", default: 0, null: false t.datetime "created_at", null: false t.bigint "external_account_id" t.string "name", null: false t.datetime "updated_at", null: false t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end create_table "action_pack_passkeys", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "aaguid" t.boolean "backed_up" t.datetime "created_at", null: false t.string "credential_id", null: false t.uuid "holder_id", null: false t.string "holder_type", null: false t.string "name" t.binary "public_key", null: false t.integer "sign_count", default: 0, null: false t.text "transports" t.datetime "updated_at", null: false t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id" end create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.text "body", size: :long t.datetime "created_at", null: false t.string "name", null: false t.uuid "record_id", null: false t.string "record_type", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false t.string "name", null: false t.uuid "record_id", null: false t.string "record_type", null: false t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.bigint "byte_size", null: false t.string "checksum" t.string "content_type" t.datetime "created_at", null: false t.string "filename", null: false t.string "key", null: false t.text "metadata" t.string "service_name", null: false t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.string "variation_digest", null: false t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end create_table "assignees_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assignee_id", null: false t.uuid "filter_id", null: false t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" end create_table "assigners_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "assigner_id", null: false t.uuid "filter_id", null: false t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" end create_table "assignments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "assignee_id", null: false t.uuid "assigner_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_assignments_on_account_id" t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true t.index ["card_id"], name: "index_assignments_on_card_id" end create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "key" t.datetime "updated_at", null: false t.index ["account_id"], name: "index_board_publications_on_account_id" t.index ["board_id"], name: "index_board_publications_on_board_id" t.index ["key"], name: "index_board_publications_on_key", unique: true end create_table "boards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.boolean "all_access", default: false, null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.string "name", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_boards_on_account_id" t.index ["creator_id"], name: "index_boards_on_creator_id" end create_table "boards_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "board_id", null: false t.uuid "filter_id", null: false t.index ["board_id"], name: "index_boards_filters_on_board_id" t.index ["filter_id"], name: "index_boards_filters_on_filter_id" end create_table "card_activity_spikes", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false 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", unique: true end create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_card_goldnesses_on_account_id" t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true end create_table "card_not_nows", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["account_id"], name: "index_card_not_nows_on_account_id" t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true t.index ["user_id"], name: "index_card_not_nows_on_user_id" end create_table "cards", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.uuid "column_id" t.datetime "created_at", null: false t.uuid "creator_id", null: false t.date "due_on" t.datetime "last_active_at", null: false t.bigint "number", null: false t.string "status", default: "drafted", null: false t.string "title" t.datetime "updated_at", null: false t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true t.index ["board_id"], name: "index_cards_on_board_id" t.index ["column_id"], name: "index_cards_on_column_id" end create_table "closers_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "closer_id", null: false t.uuid "filter_id", null: false t.index ["closer_id"], name: "index_closers_filters_on_closer_id" t.index ["filter_id"], name: "index_closers_filters_on_filter_id" end create_table "closures", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["account_id"], name: "index_closures_on_account_id" t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" t.index ["card_id"], name: "index_closures_on_card_id", unique: true t.index ["user_id"], name: "index_closures_on_user_id" end create_table "columns", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.string "color", null: false t.datetime "created_at", null: false t.string "name", null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_columns_on_account_id" t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" t.index ["board_id"], name: "index_columns_on_board_id" end create_table "comments", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_comments_on_account_id" t.index ["card_id"], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "creator_id", null: false t.uuid "filter_id", null: false t.index ["creator_id"], name: "index_creators_filters_on_creator_id" t.index ["filter_id"], name: "index_creators_filters_on_filter_id" end create_table "entropies", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.bigint "auto_postpone_period", default: 2592000, null: false t.uuid "container_id", null: false t.string "container_type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_entropies_on_account_id" t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true end create_table "events", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "action", null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.uuid "eventable_id", null: false t.string "eventable_type", null: false t.json "particulars", default: -> { "(json_object())" } t.datetime "updated_at", null: false t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" t.index ["board_id"], name: "index_events_on_board_id" t.index ["creator_id"], name: "index_events_on_creator_id" t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end create_table "exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", default: "pending", null: false t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_exports_on_account_id" t.index ["type"], name: "index_exports_on_type" t.index ["user_id"], name: "index_exports_on_user_id" end create_table "filters", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.json "fields", default: -> { "(json_object())" }, null: false t.string "params_digest", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_filters_on_account_id" t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true end create_table "filters_tags", id: false, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "filter_id", null: false t.uuid "tag_id", null: false t.index ["filter_id"], name: "index_filters_tags_on_filter_id" t.index ["tag_id"], name: "index_filters_tags_on_tag_id" end create_table "identities", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "email_address", null: false t.boolean "staff", default: false, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_identities_on_email_address", unique: true end create_table "identity_access_tokens", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.text "description" t.uuid "identity_id", null: false t.string "permission" t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "code", null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.uuid "identity_id" t.integer "purpose", null: false t.datetime "updated_at", null: false t.index ["code"], name: "index_magic_links_on_code", unique: true t.index ["expires_at"], name: "index_magic_links_on_expires_at" t.index ["identity_id"], name: "index_magic_links_on_identity_id" end create_table "mentions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "mentionee_id", null: false t.uuid "mentioner_id", null: false t.uuid "source_id", null: false t.string "source_type", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_mentions_on_account_id" t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" t.index ["source_type", "source_id"], name: "index_mentions_on_source" end create_table "notification_bundles", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.datetime "ends_at", null: false t.datetime "starts_at", null: false t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notification_bundles_on_account_id" t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" end create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", null: false t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_pins_on_account_id" t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true t.index ["card_id"], name: "index_pins_on_card_id" t.index ["user_id"], name: "index_pins_on_user_id" end create_table "push_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "auth_key" t.datetime "created_at", null: false t.text "endpoint" t.string "p256dh_key" t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.uuid "user_id", null: false t.index ["account_id"], name: "index_push_subscriptions_on_account_id" t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true, length: { endpoint: 255 } end create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "content", limit: 16, null: false t.datetime "created_at", null: false t.uuid "reactable_id", null: false t.string "reactable_type", null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_reactions_on_account_id" t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id" t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end create_table "search_queries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.string "terms", limit: 2000, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_search_queries_on_account_id" t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms", length: { terms: 255 } t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true t.index ["user_id"], name: "index_search_queries_on_user_id" end create_table "search_records_0", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_0_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_0_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_0_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_1", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_1_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_1_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_1_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_10", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_10_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_10_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_10_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_11", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_11_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_11_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_11_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_12", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_12_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_12_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_12_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_13", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_13_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_13_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_13_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_14", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_14_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_14_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_14_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_15", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_15_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_15_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_15_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_2", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_2_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_2_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_2_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_3", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_3_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_3_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_3_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_4", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_4_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_4_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_4_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_5", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_5_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_5_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_5_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_6", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_6_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_6_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_6_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_7", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_7_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_7_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_7_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_8", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_8_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_8_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_8_on_searchable_type_and_searchable_id", unique: true end create_table "search_records_9", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.string "account_key", default: "", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content" t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", null: false t.string "title" t.index ["account_id"], name: "index_search_records_9_on_account_id" t.index ["account_key", "content", "title"], name: "index_search_records_9_on_account_key_and_content_and_title", type: :fulltext t.index ["searchable_type", "searchable_id"], name: "index_search_records_9_on_searchable_type_and_searchable_id", unique: true end create_table "sessions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false t.string "ip_address" t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.index ["identity_id"], name: "index_sessions_on_identity_id" end create_table "steps", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.boolean "completed", default: false, null: false t.text "content", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_steps_on_account_id" t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" t.index ["card_id"], name: "index_steps_on_card_id" end create_table "storage_entries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id" t.uuid "board_id" t.datetime "created_at", null: false t.bigint "delta", null: false t.string "operation", null: false t.uuid "recordable_id" t.string "recordable_type" t.string "request_id" t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" t.index ["board_id"], name: "index_storage_entries_on_board_id" t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" t.index ["request_id"], name: "index_storage_entries_on_request_id" t.index ["user_id"], name: "index_storage_entries_on_user_id" end create_table "storage_totals", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "bytes_stored", default: 0, null: false t.datetime "created_at", null: false t.uuid "last_entry_id" t.uuid "owner_id", null: false t.string "owner_type", null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true end create_table "taggings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "tag_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_taggings_on_account_id" t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" end create_table "tags", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.string "title" t.datetime "updated_at", null: false t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "user_settings", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.integer "bundle_email_frequency", default: 0, null: false t.datetime "created_at", null: false t.string "timezone_name" t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_user_settings_on_account_id" t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" t.index ["user_id"], name: "index_user_settings_on_user_id" end create_table "users", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.uuid "identity_id" t.string "name", null: false t.string "role", default: "member", null: false t.datetime "updated_at", null: false t.datetime "verified_at" t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" t.index ["identity_id"], name: "index_users_on_identity_id" end create_table "watches", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.boolean "watching", default: true, null: false t.index ["account_id"], name: "index_watches_on_account_id" t.index ["card_id"], name: "index_watches_on_card_id" t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" t.index ["user_id"], name: "index_watches_on_user_id" end create_table "webhook_delinquency_trackers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.integer "consecutive_failures_count", default: 0 t.datetime "created_at", null: false t.datetime "first_failure_at" t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" end create_table "webhook_deliveries", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "event_id", null: false t.text "request" t.text "response" t.string "state", null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" t.index ["created_at"], name: "index_webhook_deliveries_on_created_at" t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end create_table "webhooks", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.boolean "active", default: true, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "name" t.string "signing_secret", null: false t.text "subscribed_actions" t.datetime "updated_at", null: false t.text "url", null: false 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 end ================================================ FILE: db/schema_sqlite.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "involvement", limit: 255, default: "access_only", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id", "accessed_at"], name: "index_accesses_on_account_id_and_accessed_at" t.index ["board_id", "user_id"], name: "index_accesses_on_board_id_and_user_id", unique: true t.index ["board_id"], name: "index_accesses_on_board_id" t.index ["user_id"], name: "index_accesses_on_user_id" end create_table "account_cancellations", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "initiated_by_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true end create_table "account_external_id_sequences", id: :uuid, force: :cascade do |t| t.bigint "value", default: 0, null: false t.index ["value"], name: "index_account_external_id_sequences_on_value", unique: true end create_table "account_imports", id: :uuid, force: :cascade do |t| t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false t.string "failure_reason", limit: 255 t.uuid "identity_id", null: false t.string "status", limit: 255, default: "pending", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_imports_on_account_id" t.index ["identity_id"], name: "index_account_imports_on_identity_id" end create_table "account_join_codes", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "usage_count", default: 0, null: false t.bigint "usage_limit", default: 10, null: false t.index ["account_id", "code"], name: "index_account_join_codes_on_account_id_and_code", unique: true end create_table "accounts", id: :uuid, force: :cascade do |t| t.bigint "cards_count", default: 0, null: false t.datetime "created_at", null: false t.bigint "external_account_id" t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true end create_table "action_pack_passkeys", id: :uuid, force: :cascade do |t| t.string "aaguid", limit: 255 t.boolean "backed_up" t.datetime "created_at", null: false t.string "credential_id", limit: 255, null: false t.uuid "holder_id", null: false t.string "holder_type", limit: 255, null: false t.string "name", limit: 255 t.binary "public_key", null: false t.integer "sign_count", default: 0, null: false t.text "transports", limit: 65535 t.datetime "updated_at", null: false t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id" end create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.text "body", limit: 4294967295 t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.uuid "record_id", null: false t.string "record_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_action_text_rich_texts_on_account_id" t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end create_table "active_storage_attachments", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.uuid "record_id", null: false t.string "record_type", limit: 255, null: false t.index ["account_id"], name: "index_active_storage_attachments_on_account_id" t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.bigint "byte_size", null: false t.string "checksum", limit: 255 t.string "content_type", limit: 255 t.datetime "created_at", null: false t.string "filename", limit: 255, null: false t.string "key", limit: 255, null: false t.text "metadata", limit: 65535 t.string "service_name", limit: 255, null: false t.index ["account_id"], name: "index_active_storage_blobs_on_account_id" t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end create_table "active_storage_variant_records", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id", null: false t.string "variation_digest", limit: 255, null: false t.index ["account_id"], name: "index_active_storage_variant_records_on_account_id" t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end create_table "assignees_filters", id: false, force: :cascade do |t| t.uuid "assignee_id", null: false t.uuid "filter_id", null: false t.index ["assignee_id"], name: "index_assignees_filters_on_assignee_id" t.index ["filter_id"], name: "index_assignees_filters_on_filter_id" end create_table "assigners_filters", id: false, force: :cascade do |t| t.uuid "assigner_id", null: false t.uuid "filter_id", null: false t.index ["assigner_id"], name: "index_assigners_filters_on_assigner_id" t.index ["filter_id"], name: "index_assigners_filters_on_filter_id" end create_table "assignments", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "assignee_id", null: false t.uuid "assigner_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_assignments_on_account_id" t.index ["assignee_id", "card_id"], name: "index_assignments_on_assignee_id_and_card_id", unique: true t.index ["card_id"], name: "index_assignments_on_card_id" end create_table "board_publications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "key", limit: 255 t.datetime "updated_at", null: false t.index ["account_id"], name: "index_board_publications_on_account_id" t.index ["board_id"], name: "index_board_publications_on_board_id" t.index ["key"], name: "index_board_publications_on_key", unique: true end create_table "boards", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.boolean "all_access", default: false, null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_boards_on_account_id" t.index ["creator_id"], name: "index_boards_on_creator_id" end create_table "boards_filters", id: false, force: :cascade do |t| t.uuid "board_id", null: false t.uuid "filter_id", null: false t.index ["board_id"], name: "index_boards_filters_on_board_id" t.index ["filter_id"], name: "index_boards_filters_on_filter_id" end create_table "card_activity_spikes", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false 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", unique: true end create_table "card_goldnesses", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_card_goldnesses_on_account_id" t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true end create_table "card_not_nows", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["account_id"], name: "index_card_not_nows_on_account_id" t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true t.index ["user_id"], name: "index_card_not_nows_on_user_id" end create_table "cards", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.uuid "column_id" t.datetime "created_at", null: false t.uuid "creator_id", null: false t.date "due_on" t.datetime "last_active_at", null: false t.bigint "number", null: false t.string "status", limit: 255, default: "drafted", null: false t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["account_id", "last_active_at", "status"], name: "index_cards_on_account_id_and_last_active_at_and_status" t.index ["account_id", "number"], name: "index_cards_on_account_id_and_number", unique: true t.index ["board_id"], name: "index_cards_on_board_id" t.index ["column_id"], name: "index_cards_on_column_id" end create_table "closers_filters", id: false, force: :cascade do |t| t.uuid "closer_id", null: false t.uuid "filter_id", null: false t.index ["closer_id"], name: "index_closers_filters_on_closer_id" t.index ["filter_id"], name: "index_closers_filters_on_filter_id" end create_table "closures", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id" t.index ["account_id"], name: "index_closures_on_account_id" t.index ["card_id", "created_at"], name: "index_closures_on_card_id_and_created_at" t.index ["card_id"], name: "index_closures_on_card_id", unique: true t.index ["user_id"], name: "index_closures_on_user_id" end create_table "columns", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.string "color", limit: 255, null: false t.datetime "created_at", null: false t.string "name", limit: 255, null: false t.integer "position", default: 0, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_columns_on_account_id" t.index ["board_id", "position"], name: "index_columns_on_board_id_and_position" t.index ["board_id"], name: "index_columns_on_board_id" end create_table "comments", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_comments_on_account_id" t.index ["card_id"], name: "index_comments_on_card_id" end create_table "creators_filters", id: false, force: :cascade do |t| t.uuid "creator_id", null: false t.uuid "filter_id", null: false t.index ["creator_id"], name: "index_creators_filters_on_creator_id" t.index ["filter_id"], name: "index_creators_filters_on_filter_id" end create_table "entropies", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.bigint "auto_postpone_period", default: 2592000, null: false t.uuid "container_id", null: false t.string "container_type", limit: 255, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_entropies_on_account_id" t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_3d79b50517" t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true end create_table "events", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "action", limit: 255, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.uuid "eventable_id", null: false t.string "eventable_type", limit: 255, null: false t.json "particulars", default: -> { "json_object()" } t.datetime "updated_at", null: false t.index ["account_id", "action"], name: "index_events_on_account_id_and_action" t.index ["board_id", "action", "created_at"], name: "index_events_on_board_id_and_action_and_created_at" t.index ["board_id"], name: "index_events_on_board_id" t.index ["creator_id"], name: "index_events_on_creator_id" t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable" end create_table "exports", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", limit: 255, default: "pending", null: false t.string "type", limit: 255 t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_exports_on_account_id" t.index ["type"], name: "index_exports_on_type" t.index ["user_id"], name: "index_exports_on_user_id" end create_table "filters", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "creator_id", null: false t.json "fields", default: -> { "json_object()" }, null: false t.string "params_digest", limit: 255, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_filters_on_account_id" t.index ["creator_id", "params_digest"], name: "index_filters_on_creator_id_and_params_digest", unique: true end create_table "filters_tags", id: false, force: :cascade do |t| t.uuid "filter_id", null: false t.uuid "tag_id", null: false t.index ["filter_id"], name: "index_filters_tags_on_filter_id" t.index ["tag_id"], name: "index_filters_tags_on_tag_id" end create_table "identities", id: :uuid, force: :cascade do |t| t.datetime "created_at", null: false t.string "email_address", limit: 255, null: false t.boolean "staff", default: false, null: false t.datetime "updated_at", null: false t.index ["email_address"], name: "index_identities_on_email_address", unique: true end create_table "identity_access_tokens", id: :uuid, force: :cascade do |t| t.datetime "created_at", null: false t.text "description", limit: 65535 t.uuid "identity_id", null: false t.string "permission", limit: 255 t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" end create_table "magic_links", id: :uuid, force: :cascade do |t| t.string "code", limit: 255, null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.uuid "identity_id" t.integer "purpose", null: false t.datetime "updated_at", null: false t.index ["code"], name: "index_magic_links_on_code", unique: true t.index ["expires_at"], name: "index_magic_links_on_expires_at" t.index ["identity_id"], name: "index_magic_links_on_identity_id" end create_table "mentions", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "mentionee_id", null: false t.uuid "mentioner_id", null: false t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_mentions_on_account_id" t.index ["mentionee_id"], name: "index_mentions_on_mentionee_id" t.index ["mentioner_id"], name: "index_mentions_on_mentioner_id" t.index ["source_type", "source_id"], name: "index_mentions_on_source" end create_table "notification_bundles", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.datetime "ends_at", null: false t.datetime "starts_at", null: false t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notification_bundles_on_account_id" t.index ["ends_at", "status"], name: "index_notification_bundles_on_ends_at_and_status" t.index ["user_id", "starts_at", "ends_at"], name: "idx_on_user_id_starts_at_ends_at_7eae5d3ac5" t.index ["user_id", "status"], name: "index_notification_bundles_on_user_id_and_status" end create_table "notifications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "creator_id" t.datetime "read_at" t.uuid "source_id", null: false t.string "source_type", limit: 255, null: false t.integer "unread_count", default: 0, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_notifications_on_account_id" t.index ["creator_id"], name: "index_notifications_on_creator_id" t.index ["source_type", "source_id"], name: "index_notifications_on_source" t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc } t.index ["user_id"], name: "index_notifications_on_user_id" end create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_pins_on_account_id" t.index ["card_id", "user_id"], name: "index_pins_on_card_id_and_user_id", unique: true t.index ["card_id"], name: "index_pins_on_card_id" t.index ["user_id"], name: "index_pins_on_user_id" end create_table "push_subscriptions", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "auth_key", limit: 255 t.datetime "created_at", null: false t.text "endpoint", limit: 65535 t.string "p256dh_key", limit: 255 t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.uuid "user_id", null: false t.index ["account_id"], name: "index_push_subscriptions_on_account_id" t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true end create_table "reactions", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.string "content", limit: 16, null: false t.datetime "created_at", null: false t.uuid "reactable_id", null: false t.string "reactable_type", limit: 255, null: false t.uuid "reacter_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_reactions_on_account_id" t.index ["reactable_type", "reactable_id"], name: "index_reactions_on_reactable_type_and_reactable_id" t.index ["reacter_id"], name: "index_reactions_on_reacter_id" end create_table "search_queries", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.string "terms", limit: 2000, null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_search_queries_on_account_id" t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms" t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true t.index ["user_id"], name: "index_search_queries_on_user_id" end create_table "search_records", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false t.uuid "card_id", null: false t.text "content", limit: 65535 t.datetime "created_at", null: false t.uuid "searchable_id", null: false t.string "searchable_type", limit: 255, null: false t.string "title", limit: 255 t.index ["account_id"], name: "index_search_records_on_account_id" t.index ["searchable_type", "searchable_id"], name: "index_search_records_on_searchable_type_and_searchable_id", unique: true end create_table "sessions", id: :uuid, force: :cascade do |t| t.datetime "created_at", null: false t.uuid "identity_id", null: false t.string "ip_address", limit: 255 t.datetime "updated_at", null: false t.string "user_agent", limit: 4096 t.index ["identity_id"], name: "index_sessions_on_identity_id" end create_table "steps", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.boolean "completed", default: false, null: false t.text "content", limit: 65535, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_steps_on_account_id" t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed" t.index ["card_id"], name: "index_steps_on_card_id" end create_table "storage_entries", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "blob_id" t.uuid "board_id" t.datetime "created_at", null: false t.bigint "delta", null: false t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 t.string "request_id", limit: 255 t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" t.index ["board_id"], name: "index_storage_entries_on_board_id" t.index ["recordable_type", "recordable_id"], name: "index_storage_entries_on_recordable" t.index ["request_id"], name: "index_storage_entries_on_request_id" t.index ["user_id"], name: "index_storage_entries_on_user_id" end create_table "storage_totals", id: :uuid, force: :cascade do |t| t.bigint "bytes_stored", default: 0, null: false t.datetime "created_at", null: false t.uuid "last_entry_id" t.uuid "owner_id", null: false t.string "owner_type", limit: 255, null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id"], name: "index_storage_totals_on_owner_type_and_owner_id", unique: true end create_table "taggings", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.uuid "tag_id", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_taggings_on_account_id" t.index ["card_id", "tag_id"], name: "index_taggings_on_card_id_and_tag_id", unique: true t.index ["tag_id"], name: "index_taggings_on_tag_id" end create_table "tags", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.string "title", limit: 255 t.datetime "updated_at", null: false t.index ["account_id", "title"], name: "index_tags_on_account_id_and_title", unique: true end create_table "user_settings", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.integer "bundle_email_frequency", default: 0, null: false t.datetime "created_at", null: false t.string "timezone_name", limit: 255 t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_user_settings_on_account_id" t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency" t.index ["user_id"], name: "index_user_settings_on_user_id" end create_table "users", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.boolean "active", default: true, null: false t.datetime "created_at", null: false t.uuid "identity_id" t.string "name", limit: 255, null: false t.string "role", limit: 255, default: "member", null: false t.datetime "updated_at", null: false t.datetime "verified_at" t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true t.index ["account_id", "role"], name: "index_users_on_account_id_and_role" t.index ["identity_id"], name: "index_users_on_identity_id" end create_table "watches", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "user_id", null: false t.boolean "watching", default: true, null: false t.index ["account_id"], name: "index_watches_on_account_id" t.index ["card_id"], name: "index_watches_on_card_id" t.index ["user_id", "card_id"], name: "index_watches_on_user_id_and_card_id" t.index ["user_id"], name: "index_watches_on_user_id" end create_table "webhook_delinquency_trackers", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.integer "consecutive_failures_count", default: 0 t.datetime "created_at", null: false t.datetime "first_failure_at" t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_delinquency_trackers_on_account_id" t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id" end create_table "webhook_deliveries", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false t.uuid "event_id", null: false t.text "request", limit: 65535 t.text "response", limit: 65535 t.string "state", limit: 255, null: false t.datetime "updated_at", null: false t.uuid "webhook_id", null: false t.index ["account_id"], name: "index_webhook_deliveries_on_account_id" t.index ["created_at"], name: "index_webhook_deliveries_on_created_at" t.index ["event_id"], name: "index_webhook_deliveries_on_event_id" t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id" end create_table "webhooks", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.boolean "active", default: true, null: false t.uuid "board_id", null: false t.datetime "created_at", null: false t.string "name", limit: 255 t.string "signing_secret", limit: 255, null: false t.text "subscribed_actions", limit: 65535 t.datetime "updated_at", null: false t.text "url", limit: 65535, null: false 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" end execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )" end ================================================ FILE: db/seeds/37signals.rb ================================================ create_tenant "37signals" david = find_or_create_user "David Heinemeier Hansson", "david@example.com" jason = find_or_create_user "Jason Fried", "jason@example.com" jz = find_or_create_user "Jason Zimdars", "jz@example.com" kevin = find_or_create_user "Kevin Mcconnell", "kevin@example.com" login_as david create_board("Fizzy", access_to: [ jason, jz, kevin ]).tap do |fizzy| create_card("Prepare sign-up page", description: "We need to do this before the launch.", board: fizzy) create_card("Prepare sign-up page", description: "We need to do this before the launch.", board: fizzy).tap do |card| card.toggle_assignment(kevin) if column = card.board&.columns&.sample card.triage_into(column) end end create_card("Plain text mentions", description: "We'll support plain text mentions first.", board: fizzy).tap do |card| card.toggle_assignment(david) card.close end end ================================================ FILE: db/seeds/cleanslate.rb ================================================ create_tenant "cleanslate" ================================================ FILE: db/seeds/honcho.rb ================================================ create_tenant "Honcho" david = find_or_create_user "David Heinemeier Hansson", "david@example.com" jason = find_or_create_user "Jason Fried", "jason@example.com" jz = find_or_create_user "Jason Zimdars", "jz@example.com" kevin = find_or_create_user "Kevin McConnell", "kevin@example.com" jorge = find_or_create_user "Jorge Manrubia", "jorge@example.com" mike = find_or_create_user "Mike Dalessio", "mike@example.com" login_as david authors = [ david, jason, jz, kevin, jorge, mike ] card_titles = [ "Implement authentication", "Design landing page", "Set up database", "Create API endpoints", "Write unit tests", "Optimize performance", "Add user profiles", "Implement search", "Create admin panel", "Set up CI/CD" ] boards = [ "Project Launch", "Frontend Dev", "Backend Dev", "Design System", "Testing Suite" ] time_range = (60 .. 30.days.in_minutes) boards.each_with_index do |board_name, index| create_board(board_name, access_to: authors.sample(3)).tap do |board| card_titles.each do |title| travel(-rand(time_range).minutes) do card = create_card title, description: "#{title} for #{board_name} phase #{index + 1}.", board: board, creator: authors.sample # Randomly assign to 1-2 authors travel rand(0..20).minutes card.toggle_assignment(authors.sample) if rand > 0.5 travel rand(0..20).minutes card.toggle_assignment(authors.sample) end # Randomly set card state travel rand(0..20).minutes case rand(3) when 0 if column = card.board&.columns&.sample card.triage_into(column) end when 1 card.close # 2 remains open end end end end end ================================================ FILE: db/seeds.rb ================================================ unless Rails.env.development? puts "WARN: Seeding is just for development!" else require "active_support/testing/time_helpers" include ActiveSupport::Testing::TimeHelpers # Seed DSL def seed_account(name) print " #{name}…" elapsed = Benchmark.realtime { require_relative "seeds/#{name}" } puts " #{elapsed.round(2)} sec" end def create_tenant(signal_account_name) tenant_id = ActiveRecord::FixtureSet.identify signal_account_name email_address = "david@example.com" identity = Identity.find_or_create_by!(email_address: email_address, staff: true) unless account = Account.find_by(external_account_id: tenant_id) account = Account.create_with_owner( account: { external_account_id: tenant_id, name: signal_account_name }, owner: { name: "David Heinemeier Hansson", identity: identity } ) end Current.account = account end def find_or_create_user(full_name, email_address) identity = Identity.find_or_create_by!(email_address: email_address) if user = identity.users.find_by(account: Current.account) user else User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current) end end def login_as(user) Current.session = user.identity.sessions.create end def create_board(name, creator: Current.user, all_access: true, access_to: []) Board.find_or_create_by!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) } end def create_card(title, board:, description: nil, status: :published, creator: Current.user) board.cards.create!(title:, description:, creator:, status:) end # Seed accounts seed_account "cleanslate" seed_account "37signals" seed_account "honcho" end ================================================ FILE: docs/API.md ================================================ # Fizzy API Fizzy has an API that allows you to integrate your application with it or to create a bot to perform various actions for you. ## Authentication There are two ways to authenticate with the Fizzy API: 1. **Personal access tokens** - Long-lived tokens for scripts and integrations 2. **Magic link authentication** - Session-based authentication for native apps ### Personal Access Tokens To use the API you'll need an access token. To get one, go to your profile, then, in the API section, click on "Personal access tokens" and then click on "Generate new access token". Give it a description and pick what kind of permission you want the access token to have: - `Read`: allows reading data from your account - `Read + Write`: allows reading and writing data to your account on your behalf Then click on "Generate access token".
    Access token generation guide with screenshots | Step | Description | Screenshot | |:----:|-------------|:----------:| | 1 | Go to your profile | Profile page with API section | | 2 | In the API section click on "Personal access token" | Personal access tokens page | | 3 | Click on "Generate a new access token" | Generate new access token dialog | | 4 | Give it a description and assign it a permission | Access token created |
    > [!IMPORTANT] > __An access token is like a password, keep it secret and do not share it with anyone.__ > Any person or application that has your access token can perform actions on your behalf. To authenticate a request using your access token, include it in the `Authorization` header: ```bash curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity ``` ### Magic Link Authentication For native apps, you can authenticate users via magic links. This is a two-step process: #### 1. Request a magic link Send the user's email address to request a magic link be sent to them: ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"email_address": "user@example.com"}' \ https://app.fizzy.do/session ``` __Response:__ ``` HTTP/1.1 201 Created Set-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax ``` ```json { "pending_authentication_token": "eyJfcmFpbHMi..." } ``` The response includes a `pending_authentication_token` both in the JSON body and as a cookie. Native apps should store this token and include it as a cookie when submitting the magic link code. __Error responses:__ | Status Code | Description | |--------|-------------| | `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address | | `429 Too Many Requests` | Rate limit exceeded | #### 2. Submit the magic link code Once the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication: ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Cookie: pending_authentication_token=eyJfcmFpbHMi..." \ -d '{"code": "ABC123"}' \ https://app.fizzy.do/session/magic_link ``` __Response:__ ```json { "session_token": "eyJfcmFpbHMi..." } ``` The `session_token` can be used to authenticate subsequent requests by including it as a cookie: ```bash curl -H "Cookie: session_token=eyJfcmFpbHMi..." \ -H "Accept: application/json" \ https://app.fizzy.do/my/identity ``` __Error responses:__ | Status Code | Description | |--------|-------------| | `401 Unauthorized` | Invalid `pending_authentication_token` or `code` | | `429 Too Many Requests` | Rate limit exceeded | #### Delete server-side session (_log out_) To log out and destroy the server-side session: ```bash curl -X DELETE \ -H "Accept: application/json" \ -H "Cookie: session_token=eyJfcmFpbHMi..." \ https://app.fizzy.do/session ``` __Response:__ Returns `204 No Content` on success. #### Create an access token via the API You can programmatically create a personal access token using either a session cookie or an existing Bearer token: ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Cookie: session_token=eyJfcmFpbHMi..." \ -d '{"access_token": {"description": "Fizzy CLI", "permission": "write"}}' \ https://app.fizzy.do/1234567/my/access_tokens ``` Or with a Bearer token (must have `write` permission): ```bash curl -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Authorization: Bearer put-your-access-token-here" \ -d '{"access_token": {"description": "Fizzy CLI", "permission": "write"}}' \ https://app.fizzy.do/1234567/my/access_tokens ``` The `permission` field accepts `read` or `write`. __Response:__ ``` HTTP/1.1 201 Created ``` ```json { "token": "4f9Q6d2wXr8Kp1Ls0Vz3BnTa", "description": "Fizzy CLI", "permission": "write" } ``` Store the `token` value securely — it won't be retrievable again. Use it as a Bearer token for subsequent API requests. ## Caching Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. ### Using ETags When you make a request, the response includes an `ETag` header: ``` HTTP/1.1 200 OK ETag: "abc123" Cache-Control: max-age=0, private, must-revalidate ``` On subsequent requests, include the ETag value in the `If-None-Match` header: ``` GET /1234567/cards/42.json If-None-Match: "abc123" ``` If the resource hasn't changed, you'll receive a `304 Not Modified` response with no body, saving bandwidth and processing time: ``` HTTP/1.1 304 Not Modified ETag: "abc123" ``` If the resource has changed, you'll receive the full response with a new ETag. __Example in Ruby:__ ```ruby # Store the ETag from the response etag = response.headers["ETag"] # On next request, send it back headers = { "If-None-Match" => etag } response = client.get("/1234567/cards/42.json", headers: headers) if response.status == 304 # Nothing to do, the card hasn't changed else # The card has changed, process the new data end ``` ## Error Responses When a request fails, the API response will communicate the source of the problem through the HTTP status code. | Status Code | Description | |-------------|-------------| | `400 Bad Request` | The request was malformed or missing required parameters | | `401 Unauthorized` | Authentication failed or access token is invalid | | `403 Forbidden` | You don't have permission to perform this action | | `404 Not Found` | The requested resource doesn't exist or you don't have access to it | | `422 Unprocessable Entity` | Validation failed (see error response format above) | | `500 Internal Server Error` | An unexpected error occurred on the server | If a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request. A validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error: ```json { "avatar": ["must be a JPEG, PNG, GIF, or WebP image"] } ``` ## Pagination All endpoints that return a list of items are paginated. The page size can vary from endpoint to endpoint, and we use a dynamic page size where initial pages return fewer results than later pages. If there are more results to fetch, the response will include a `Link` header with a `rel="next"` link to the next page of results: ```bash curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" -v http://fizzy.localhost:3006/686465299/cards # ... < link: ; rel="next" # ... ``` ## List parameters When an endpoint accepts a list of values as a parameter, you can provide multiple values by repeating the parameter name: ``` ?tag_ids[]=tag1&tag_ids[]=tag2&tag_ids[]=tag3 ``` List parameters always end with `[]`. ## File Uploads Some endpoints accept file uploads. To upload a file, send a `multipart/form-data` request instead of JSON. You can combine file uploads with other parameters in the same request. __Example using curl:__ ```bash curl -X PUT \ -H "Authorization: Bearer put-your-access-token-here" \ -F "user[name]=David H. Hansson" \ -F "user[avatar]=@/path/to/avatar.jpg" \ http://fizzy.localhost:3006/686465299/users/03f5v9zjw7pz8717a4no1h8a7 ``` ## Rich Text Fields Some fields accept rich text content. These fields accept HTML input, which will be sanitized to remove unsafe tags and attributes. ```json { "card": { "title": "My card", "description": "

    This is bold and this is italic.

    • Item 1
    • Item 2
    " } } ``` ### Attaching files to rich text To attach files (images, documents) to rich text fields, use ActionText's direct upload flow: #### 1. Create a direct upload First, request a direct upload URL by sending file metadata: ```bash curl -X POST \ -H "Authorization: Bearer put-your-access-token-here" \ -H "Content-Type: application/json" \ -d '{ "blob": { "filename": "screenshot.png", "byte_size": 12345, "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", "content_type": "image/png" } }' \ https://app.fizzy.do/123456/rails/active_storage/direct_uploads ``` The `checksum` is a Base64-encoded MD5 hash of the file content. The direct upload endpoint is scoped to your account (replace `/123456` with your account slug). __Response:__ ```json { "id": "abc123", "key": "abc123def456", "filename": "screenshot.png", "content_type": "image/png", "byte_size": 12345, "checksum": "GQ5SqLsM7ylnji0Wgd9wNA==", "direct_upload": { "url": "https://storage.example.com/...", "headers": { "Content-Type": "image/png", "Content-MD5": "GQ5SqLsM7ylnji0Wgd9wNA==" } }, "signed_id": "eyJfcmFpbHMi..." } ``` #### 2. Upload the file Upload the file directly to the provided URL with the specified headers: ```bash curl -X PUT \ -H "Content-Type: image/png" \ -H "Content-MD5: GQ5SqLsM7ylnji0Wgd9wNA==" \ --data-binary @screenshot.png \ "https://storage.example.com/..." ``` #### 3. Reference the file in rich text Use the `signed_id` from step 1 to embed the file in your rich text using an `` tag: ```json { "card": { "title": "Card with image", "description": "

    Here's a screenshot:

    " } } ``` The `sgid` attribute should contain the `signed_id` returned from the direct upload response. ## Identity An Identity represents a person using Fizzy. ### `GET /my/identity` Returns a list of accounts the identity has access to, including the user for each account. ```json { "accounts": [ { "id": "03f5v9zjskhcii2r45ih3u1rq", "name": "37signals", "slug": "/897362094", "created_at": "2025-12-05T19:36:35.377Z", "user": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/users/03f5v9zjw7pz8717a4no1h8a7" } }, { "id": "03f5v9zpko7mmhjzwum3youpp", "name": "Honcho", "slug": "/686465299", "created_at": "2025-12-05T19:36:36.746Z", "user": { "id": "03f5v9zppzlksuj4mxba2nbzn", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:36.783Z", "url": "http://fizzy.localhost:3006/users/03f5v9zppzlksuj4mxba2nbzn" } } ] } ``` ## Boards Boards are where you organize your work - they contain your cards. ### `GET /:account_slug/boards` Returns a list of boards that you can access in the specified account. __Response:__ ```json [ { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } } ] ``` ### `GET /:account_slug/boards/:board_id` Returns the specified board. __Response:__ ```json { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "public_url": "http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp" } ``` The `public_url` field is only present when the board is published. ### `POST /:account_slug/boards` Creates a new Board in the account. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | The name of the board | | `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` | | `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) | | `public_description` | string | No | Rich text description shown on the public board page | __Request:__ ```json { "board": { "name": "My new board", } } ``` __Response:__ Returns `201 Created` with a `Location` header pointing to the new board: ``` HTTP/1.1 201 Created Location: /897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm.json ``` ### `PUT /:account_slug/boards/:board_id` Updates a Board. Only board administrators can update a board. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | No | The name of the board | | `all_access` | boolean | No | Whether any user in the account can access this board | | `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) | | `public_description` | string | No | Rich text description shown on the public board page | | `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) | __Request:__ ```json { "board": { "name": "Updated board name", "auto_postpone_period_in_days": 30, "public_description": "This is a **public** description of the board.", "all_access": false, "user_ids": [ "03f5v9zppzlksuj4mxba2nbzn", "03f5v9zjw7pz8717a4no1h8a7" ] } } ``` __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/boards/:board_id` Deletes a Board. Only board administrators can delete a board. __Response:__ Returns `204 No Content` on success. ## Board Publications Publishing a board makes it publicly accessible via a shareable link, without requiring authentication. Only board administrators can publish or unpublish a board. ### `POST /:account_slug/boards/:board_id/publication` Publishes a board, generating a shareable public link. __Response:__ ``` HTTP/1.1 201 Created ``` ```json { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "public_url": "http://fizzy.localhost:3006/897362094/public/boards/aB3dEfGhIjKlMnOp" } ``` If the board is already published, the existing publication is returned. ### `DELETE /:account_slug/boards/:board_id/publication` Unpublishes a board, removing public access. __Response:__ Returns `204 No Content` on success. ## Account ### `GET /account/settings` Returns the current account. __Response:__ ```json { "id": "03f5v9zjvypwh0t0e2rfh0h7k", "name": "37signals", "cards_count": 5, "created_at": "2025-12-05T19:36:35.401Z", "auto_postpone_period_in_days": 30 } ``` The `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to "Not Now" after this period of inactivity. Each board can override this with its own value. ### `PUT /account/entropy` Updates the account-level default auto close period. Requires admin role. __Request:__ ```json { "entropy": { "auto_postpone_period_in_days": 30 } } ``` __Response:__ Returns the account object: ```json { "id": "03f5v9zjvypwh0t0e2rfh0h7k", "name": "37signals", "cards_count": 5, "created_at": "2025-12-05T19:36:35.401Z", "auto_postpone_period_in_days": 30 } ``` ### `PUT /:account_slug/boards/:board_id/entropy` Updates the auto close period for a specific board. Requires board admin permission. __Request:__ ```json { "board": { "auto_postpone_period_in_days": 90 } } ``` __Response:__ Returns the board object. ## Webhooks Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks. ### `GET /:account_slug/boards/:board_id/webhooks` Returns a paginated list of webhooks for a board. __Response:__ ```json [ { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Production API", "payload_url": "https://api.example.com/webhooks", "active": true, "signing_secret": "p94Bx2HjempCdYB4DTyZkY1b", "subscribed_actions": ["card_published", "card_assigned", "card_closed"], "created_at": "2025-12-05T19:36:35.534Z", "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm", "board": { "id": "03f5v9zkft4hj9qq0lsn9ohcy", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } } } ] ``` ### `GET /:account_slug/boards/:board_id/webhooks/:id` Returns a single webhook. __Response:__ Returns the same webhook shape shown above. ### `POST /:account_slug/boards/:board_id/webhooks` Creates a webhook. __Request:__ ```json { "webhook": { "name": "Production API", "url": "https://api.example.com/webhooks", "subscribed_actions": ["card_published", "card_assigned", "card_closed"] } } ``` `subscribed_actions` accepts any of: `card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created` __Response:__ ``` HTTP/1.1 201 Created Location: http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm.json ``` Returns the created webhook in the response body. ### `PATCH /:account_slug/boards/:board_id/webhooks/:id` Updates a webhook. __Request:__ ```json { "webhook": { "name": "Production API", "subscribed_actions": ["card_closed"] } } ``` The `url` is immutable after creation and is ignored on update. __Response:__ Returns the updated webhook. ### `DELETE /:account_slug/boards/:board_id/webhooks/:id` Deletes a webhook. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/boards/:board_id/webhooks/:id/activation` Reactivates a deactivated webhook. __Response:__ ``` HTTP/1.1 201 Created ``` Returns the reactivated webhook in the response body. ## Cards Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments. ### `GET /:account_slug/cards` Returns a paginated list of cards you have access to. Results can be filtered using query parameters. __Query Parameters:__ | Parameter | Description | |-----------|-------------| | `board_ids[]` | Filter by board ID(s) | | `tag_ids[]` | Filter by tag ID(s) | | `assignee_ids[]` | Filter by assignee user ID(s) | | `creator_ids[]` | Filter by card creator ID(s) | | `closer_ids[]` | Filter by user ID(s) who closed the cards | | `card_ids[]` | Filter to specific card ID(s) | | `indexed_by` | Filter by: `all` (default), `closed`, `not_now`, `stalled`, `postponing_soon`, `golden` | | `sorted_by` | Sort order: `latest` (default), `newest`, `oldest` | | `assignment_status` | Filter by assignment status: `unassigned` | | `creation` | Filter by creation date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | | `closure` | Filter by closure date: `today`, `yesterday`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`, `thisyear`, `lastyear` | | `terms[]` | Search terms to filter cards | __Response:__ ```json [ { "id": "03f5vaeq985jlvwv3arl4srq2", "number": 1, "title": "First!", "status": "published", "description": "Hello, World!", "description_html": "

    Hello, World!

    ", "image_url": null, "has_attachments": false, "tags": ["programming"], "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", "url": "http://fizzy.localhost:3006/897362094/cards/4", "board": { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments", "reactions_url": "http://fizzy.localhost:3006/897362094/cards/4/reactions" }, ] ``` ### `GET /:account_slug/cards/:card_number` Returns a specific card by its number. __Response:__ ```json { "id": "03f5vaeq985jlvwv3arl4srq2", "number": 1, "title": "First!", "status": "published", "description": "Hello, World!", "description_html": "

    Hello, World!

    ", "image_url": null, "has_attachments": false, "tags": ["programming"], "closed": false, "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", "url": "http://fizzy.localhost:3006/897362094/cards/4", "board": { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } }, "column": { "id": "03f5v9zkft4hj9qq0lsn9ohcn", "name": "In Progress", "color": { "name": "Lime", "value": "var(--color-card-4)" }, "created_at": "2025-12-05T19:36:35.534Z" }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments", "reactions_url": "http://fizzy.localhost:3006/897362094/cards/4/reactions", "steps": [ { "id": "03f8huu0sog76g3s975963b5e", "content": "This is the first step", "completed": false }, { "id": "03f8huu0sog76g3s975969734", "content": "This is the second step", "completed": false } ] } ``` > **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field. ### `POST /:account_slug/boards/:board_id/cards` Creates a new card in a board. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `title` | string | Yes | The title of the card | | `description` | string | No | Rich text description of the card | | `status` | string | No | Initial status: `published` (default), `drafted` | | `image` | file | No | Header image for the card | | `tag_ids` | array | No | Array of tag IDs to apply to the card | | `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | | `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | __Request:__ ```json { "card": { "title": "Add dark mode support", "description": "We need to add dark mode to the app" } } ``` __Response:__ Returns `201 Created` with a `Location` header pointing to the new card. ### `PUT /:account_slug/cards/:card_number` Updates a card. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `title` | string | No | The title of the card | | `description` | string | No | Rich text description of the card | | `status` | string | No | Card status: `drafted`, `published` | | `image` | file | No | Header image for the card | | `tag_ids` | array | No | Array of tag IDs to apply to the card | | `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | __Request:__ ```json { "card": { "title": "Add dark mode support (Updated)" } } ``` __Response:__ Returns the updated card. ### `DELETE /:account_slug/cards/:card_number` Deletes a card. Only the card creator or board administrators can delete cards. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/image` Removes the header image from a card. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/closure` Closes a card. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/closure` Reopens a closed card. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/not_now` Moves a card to "Not Now" status. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/triage` Moves a card into a column. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `column_id` | string | Yes | The ID of the column to move the card into | __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/triage` Sends a card back to triage. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/taggings` Toggles a tag on or off for a card. If the tag doesn't exist, it will be created. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `tag_title` | string | Yes | The title of the tag (leading `#` is stripped) | __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/assignments` Toggles assignment of a user to/from a card. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `assignee_id` | string | Yes | The ID of the user to assign/unassign | __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/watch` Subscribes the current user to notifications for this card. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/watch` Unsubscribes the current user from notifications for this card. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/cards/:card_number/goldness` Marks a card as golden. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/goldness` Removes golden status from a card. __Response:__ Returns `204 No Content` on success. ## Pins Pins let users keep quick access to important cards. ### `POST /:account_slug/cards/:card_number/pin` Pins a card for the current user. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/pin` Unpins a card for the current user. __Response:__ Returns `204 No Content` on success. ### `GET /my/pins` Returns the current user's pinned cards. This endpoint is not paginated and returns up to 100 cards. __Response:__ ```json [ { "id": "03f5vaeq985jlvwv3arl4srq2", "number": 1, "title": "First!", "status": "published", "description": "Hello, World!", "description_html": "

    Hello, World!

    ", "image_url": null, "has_attachments": false, "tags": ["programming"], "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", "url": "http://fizzy.localhost:3006/897362094/cards/4", "board": { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7", "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar" } }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7", "avatar_url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7/avatar" }, "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments" } ] ``` ## Comments Comments are attached to cards and support rich text. ### `GET /:account_slug/cards/:card_number/comments` Returns a paginated list of comments on a card, sorted chronologically (oldest first). __Response:__ ```json [ { "id": "03f5v9zo9qlcwwpyc0ascnikz", "created_at": "2025-12-05T19:36:35.534Z", "updated_at": "2025-12-05T19:36:35.534Z", "body": { "plain_text": "This looks great!", "html": "
    This looks great!
    " }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "card": { "id": "03f5v9zo9qlcwwpyc0ascnikz", "url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz" }, "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" } ] ``` ### `GET /:account_slug/cards/:card_number/comments/:comment_id` Returns a specific comment. __Response:__ ```json { "id": "03f5v9zo9qlcwwpyc0ascnikz", "created_at": "2025-12-05T19:36:35.534Z", "updated_at": "2025-12-05T19:36:35.534Z", "body": { "plain_text": "This looks great!", "html": "
    This looks great!
    " }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "card": { "id": "03f5v9zo9qlcwwpyc0ascnikz", "url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz" }, "reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions", "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz" } ``` ### `POST /:account_slug/cards/:card_number/comments` Creates a new comment on a card. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `body` | string | Yes | The comment body (supports rich text) | | `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | __Request:__ ```json { "comment": { "body": "This looks great!" } } ``` __Response:__ Returns `201 Created` with a `Location` header pointing to the new comment. ### `PUT /:account_slug/cards/:card_number/comments/:comment_id` Updates a comment. Only the comment creator can update their comments. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `body` | string | Yes | The updated comment body | __Request:__ ```json { "comment": { "body": "This looks even better now!" } } ``` __Response:__ Returns the updated comment. ### `DELETE /:account_slug/cards/:card_number/comments/:comment_id` Deletes a comment. Only the comment creator can delete their comments. __Response:__ Returns `204 No Content` on success. ## Card Reactions (Boosts) Card reactions (also called "boosts") let users add short responses directly to cards. These are limited to 16 characters. ### `GET /:account_slug/cards/:card_number/reactions` Returns a list of reactions on a card. __Response:__ ```json [ { "id": "03f5v9zo9qlcwwpyc0ascnikz", "content": "👍", "reacter": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "url": "http://fizzy.localhost:3006/897362094/cards/3/reactions/03f5v9zo9qlcwwpyc0ascnikz" } ] ``` ### `POST /:account_slug/cards/:card_number/reactions` Adds a reaction (boost) to a card. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `content` | string | Yes | The reaction text (max 16 characters) | __Request:__ ```json { "reaction": { "content": "Great 👍" } } ``` __Response:__ Returns `201 Created` on success. ### `DELETE /:account_slug/cards/:card_number/reactions/:reaction_id` Removes your reaction from a card. Only the reaction creator can remove their own reactions. __Response:__ Returns `204 No Content` on success. ## Comment Reactions Reactions are short (16-character max) responses to comments. ### `GET /:account_slug/cards/:card_number/comments/:comment_id/reactions` Returns a list of reactions on a comment. __Response:__ ```json [ { "id": "03f5v9zo9qlcwwpyc0ascnikz", "content": "👍", "reacter": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions/03f5v9zo9qlcwwpyc0ascnikz" } ] ``` ### `POST /:account_slug/cards/:card_number/comments/:comment_id/reactions` Adds a reaction to a comment. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `content` | string | Yes | The reaction text | __Request:__ ```json { "reaction": { "content": "Great 👍" } } ``` __Response:__ Returns `201 Created` on success. ### `DELETE /:account_slug/cards/:card_number/comments/:comment_id/reactions/:reaction_id` Removes your reaction from a comment. __Response:__ Returns `204 No Content` on success. ## Steps Steps are to-do items on a card. ### `GET /:account_slug/cards/:card_number/steps/:step_id` Returns a specific step. __Response:__ ```json { "id": "03f5v9zo9qlcwwpyc0ascnikz", "content": "Write tests", "completed": false } ``` ### `POST /:account_slug/cards/:card_number/steps` Creates a new step on a card. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `content` | string | Yes | The step text | | `completed` | boolean | No | Whether the step is completed (default: `false`) | __Request:__ ```json { "step": { "content": "Write tests" } } ``` __Response:__ Returns `201 Created` with a `Location` header pointing to the new step. ### `PUT /:account_slug/cards/:card_number/steps/:step_id` Updates a step. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `content` | string | No | The step text | | `completed` | boolean | No | Whether the step is completed | __Request:__ ```json { "step": { "completed": true } } ``` __Response:__ Returns the updated step. ### `DELETE /:account_slug/cards/:card_number/steps/:step_id` Deletes a step. __Response:__ Returns `204 No Content` on success. ## Tags Tags are labels that can be applied to cards for organization and filtering. ### `GET /:account_slug/tags` Returns a list of all tags in the account, sorted alphabetically. __Response:__ ```json [ { "id": "03f5v9zo9qlcwwpyc0ascnikz", "title": "bug", "created_at": "2025-12-05T19:36:35.534Z", "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnikz" }, { "id": "03f5v9zo9qlcwwpyc0ascnilz", "title": "feature", "created_at": "2025-12-05T19:36:35.534Z", "url": "http://fizzy.localhost:3006/897362094/cards?tag_ids[]=03f5v9zo9qlcwwpyc0ascnilz" } ] ``` ## Columns Columns represent stages in a workflow on a board. Cards move through columns as they progress. ### `GET /:account_slug/boards/:board_id/columns` Returns a list of columns on a board, sorted by position. __Response:__ ```json [ { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Recording", "color": "var(--color-card-default)", "created_at": "2025-12-05T19:36:35.534Z" }, { "id": "03f5v9zkft4hj9qq0lsn9ohcn", "name": "Published", "color": "var(--color-card-4)", "created_at": "2025-12-05T19:36:35.534Z" } ] ``` ### `GET /:account_slug/boards/:board_id/columns/:column_id` Returns the specified column. __Response:__ ```json { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "In Progress", "color": "var(--color-card-default)", "created_at": "2025-12-05T19:36:35.534Z" } ``` ### `POST /:account_slug/boards/:board_id/columns` Creates a new column on the board. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | Yes | The name of the column | | `color` | string | No | The column color. One of: `var(--color-card-default)` (Blue), `var(--color-card-1)` (Gray), `var(--color-card-2)` (Tan), `var(--color-card-3)` (Yellow), `var(--color-card-4)` (Lime), `var(--color-card-5)` (Aqua), `var(--color-card-6)` (Violet), `var(--color-card-7)` (Purple), `var(--color-card-8)` (Pink) | __Request:__ ```json { "column": { "name": "In Progress", "color": "var(--color-card-4)" } } ``` __Response:__ Returns `201 Created` with a `Location` header pointing to the new column. ### `PUT /:account_slug/boards/:board_id/columns/:column_id` Updates a column. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | No | The name of the column | | `color` | string | No | The column color | __Request:__ ```json { "column": { "name": "Done" } } ``` __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/boards/:board_id/columns/:column_id` Deletes a column. __Response:__ Returns `204 No Content` on success. ## Users Users represent people who have access to an account. ### `GET /:account_slug/users` Returns a list of active users in the account. __Response:__ ```json [ { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, { "id": "03f5v9zjysoy0fqs9yg0ei3hq", "name": "Jason Fried", "role": "member", "active": true, "email_address": "jason@example.com", "created_at": "2025-12-05T19:36:35.419Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjysoy0fqs9yg0ei3hq" }, { "id": "03f5v9zk1dtqduod5bkhv3k8m", "name": "Jason Zimdars", "role": "member", "active": true, "email_address": "jz@example.com", "created_at": "2025-12-05T19:36:35.435Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk1dtqduod5bkhv3k8m" }, { "id": "03f5v9zk3nw9ja92e7s4h2wbe", "name": "Kevin Mcconnell", "role": "member", "active": true, "email_address": "kevin@example.com", "created_at": "2025-12-05T19:36:35.451Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zk3nw9ja92e7s4h2wbe" } ] ``` ### `GET /:account_slug/users/:user_id` Returns the specified user. __Response:__ ```json { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } ``` ### `PUT /:account_slug/users/:user_id` Updates a user. You can only update users you have permission to change. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `name` | string | No | The user's display name | | `avatar` | file | No | The user's avatar image | __Request:__ ```json { "user": { "name": "David H. Hansson" } } ``` __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/users/:user_id` Deactivates a user. You can only deactivate users you have permission to change. __Response:__ Returns `204 No Content` on success. ## Notifications Notifications inform users about events that happened in the account, such as comments, assignments, and card updates. ### `GET /:account_slug/notifications` Returns a list of notifications for the current user. Unread notifications are returned first, followed by read notifications. __Response:__ ```json [ { "id": "03f5va03bpuvkcjemcxl73ho2", "read": false, "read_at": null, "created_at": "2025-11-19T04:03:58.000Z", "title": "Plain text mentions", "body": "Assigned to self", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", "role": "owner", "active": true, "email_address": "david@example.com", "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, "card": { "id": "03f5v9zo9qlcwwpyc0ascnikz", "title": "Plain text mentions", "status": "published", "url": "http://fizzy.localhost:3006/897362094/cards/3" }, "url": "http://fizzy.localhost:3006/897362094/notifications/03f5va03bpuvkcjemcxl73ho2" } ] ``` ### `POST /:account_slug/notifications/:notification_id/reading` Marks a notification as read. __Response:__ Returns `204 No Content` on success. ### `DELETE /:account_slug/notifications/:notification_id/reading` Marks a notification as unread. __Response:__ Returns `204 No Content` on success. ### `POST /:account_slug/notifications/bulk_reading` Marks all unread notifications as read. __Response:__ Returns `204 No Content` on success. ================================================ FILE: docs/development.md ================================================ ## Development ### Setting up First, get everything installed and configured with: ```sh bin/setup bin/setup --reset # Reset the database and seed it ``` And then run the development server: ```sh bin/dev ``` You'll be able to access the app in development at http://fizzy.localhost:3006. To login, enter `david@example.com` and grab the verification code from the browser console to sign in. ### Web Push Notifications Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables: - `VAPID_PRIVATE_KEY` - `VAPID_PUBLIC_KEY` Generate them with the `web-push` gem: ```ruby vapid_key = WebPush.generate_key puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" ``` ### Running tests For fast feedback loops, unit tests can be run with: ```sh bin/rails test ``` The full continuous integration tests can be run with: ```sh bin/ci ``` ### Database configuration Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: ```sh DATABASE_ADAPTER=mysql bin/setup --reset DATABASE_ADAPTER=mysql bin/ci ``` The remote CI pipeline will run tests against both SQLite and MySQL. ### Outbound Emails You can view email previews at http://fizzy.localhost:3006/rails/mailers. You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: ```sh bin/rails dev:email ``` Under the hood, this will create or remove `tmp/email-dev.txt`. ## SaaS gem 37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy/tree/main/saas), a companion gem that links Fizzy with our billing system and contains our production setup. This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. ================================================ FILE: docs/docker-deployment.md ================================================ ## Deploying with Docker We provide pre-built Docker images that can be used to run Fizzy on your own server. If you don't need to change the source code, and just want the out-of-the-box Fizzy experience, this can be a great way to get started. You'll find the latest version of Fizzy's Docker image at `ghcr.io/basecamp/fizzy:main`. To run it you'll need three things: a machine that runs Docker; a mounted volume (so that your database is stored somewhere that is kept around between restarts); and some environment variables for configuration. ### Mounting a storage volume The standard Fizzy setup keeps all of its storage inside the path `/rails/storage`. By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location. The simplest way to do this is with the `--volume` flag with `docker run`. For example: ```sh docker run --volume fizzy:/rails/storage ghcr.io/basecamp/fizzy:main ``` That will create a named volume (called `fizzy`) and mount it into the correct path. Docker will manage where that volume is actually stored on your server. You can also specify the data location yourself, mount a network drive, and more. Check the Docker documentation to find out more about what's available. ### Configuring with environment variables To configure your Fizzy installation, you can use environment variables. Fizzy has several of them. Many of these are optional, but at a minimum you'll want to configure your secret key, your SSL domain, and your SMTP email settings. #### Secret Key Base Various features inside Fizzy rely on cryptography to work (such as secure links). To set this up, you need to provide a secret value that will be used as the basis of those secrets. This value can be anything, but it should be unguessable, and specific to your instance. You can use any long random string for this, or you can have the Fizzy codebase generate one for you by running: ```sh bin/rails secret ``` Once you have one, set it in the `SECRET_KEY_BASE` environment variable: ```sh docker run --env SECRET_KEY_BASE=abcdefabcdef ... ``` #### SSL If you want the Fizzy container to handle its own SSL automatically, you just need to specify the domain name that you're running it on. You can do that with the `TLS_DOMAIN` environment variable. Note that if you're using SSL, you'll want to allow traffic on ports 80 and 443. So if you were running on `fizzy.example.com` you could enable SSL like this: ```sh docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=fizzy.example.com ... ``` If you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80: ```sh docker run --publish 80:80 ... ``` If you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead: ```sh docker run --publish 80:80 --env DISABLE_SSL=true ... ``` #### SMTP Email Fizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails. The easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on). If email is not configured, you can still sign in by finding the 6-character verification code in your Docker container's logs. You can then plug all your SMTP settings from that provider into Fizzy via the following environment variables: - `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email - `SMTP_ADDRESS` - the address of the SMTP server you'll send through - `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise) - `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server Less commonly, you might also need to set some of the following: - `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this - `SMTP_DOMAIN` - the domain name advertised to the server when connecting - `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain` - `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs) You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration). #### Base URL Fizzy needs to know the public URL of your instance so it can generate correct links in certain situations (like when sending emails). Set `BASE_URL` to the full URL where your Fizzy instance is accessible: ```sh docker run --env BASE_URL=https://fizzy.example.com ... ``` #### VAPID keys Fizzy can also send Web Push notifications. To do this it needs a VAPID key pair. You can create your own keys by starting a development console with: ```sh bin/rails c ``` And then run the following to create the keypair: ```ruby vapid_key = WebPush.generate_key puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" ``` Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variables. #### S3 storage (optional) If you'd prefer that uploaded files were stored in an S3 bucket rather than in your mounted volume, you can set that up. First set `ACTIVE_STORAGE_SERVICE` to `s3`. Then set the following as appropriate for your S3 bucket: - `S3_BUCKET` - `S3_REGION` - `S3_ACCESS_KEY_ID` - `S3_SECRET_ACCESS_KEY` - `CSP_CONNECT_SRC` If you're using a provider other than AWS, you will also need some of the following: - `S3_ENDPOINT` - `S3_FORCE_PATH_STYLE` - `S3_REQUEST_CHECKSUM_CALCULATION` - `S3_RESPONSE_CHECKSUM_VALIDATION` #### Multi-tenant mode By default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like). This is for convenience: typically when you self-host you'll be running a single account, so in this mode new account signups are automatically disabled as soon as you've created your first account. If you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true` ## Example Here's an example of a `docker-compose.yml` that you could use to run Fizzy via `docker compose up` ```yaml services: web: image: ghcr.io/basecamp/fizzy:main restart: unless-stopped ports: - "80:80" - "443:443" environment: - SECRET_KEY_BASE=abcdefabcdef - TLS_DOMAIN=fizzy.example.com - BASE_URL=https://fizzy.example.com - MAILER_FROM_ADDRESS=fizzy@example.com - SMTP_ADDRESS=mail.example.com - SMTP_USERNAME=user - SMTP_PASSWORD=pass - VAPID_PRIVATE_KEY=myvapidprivatekey - VAPID_PUBLIC_KEY=myvapidpublickey volumes: - fizzy:/rails/storage volumes: fizzy: ``` ================================================ FILE: docs/kamal-deployment.md ================================================ ## Deploying Fizzy with Kamal If you'd like to run Fizzy on your own server while having the freedom to easily make changes to its code, we recommend deploying it with [Kamal](https://kamal-deploy.org/). Kamal makes it easy to set up a bare server, copy the application to it, and manage the configuration settings that it uses. (Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).) This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it. The steps to configure your very own Fizzy are: 1. Fork the repo 2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`. 3. Edit a few things in config/deploy.yml and .kamal/secrets 4. Run `kamal setup` to do your first deploy. We'll go through each of these in turn. ### Fork the repo To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo. That allows you to commit your changes, and track them over time. You can always re-sync your fork to pick up new changes from the main repo over time. Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed. ### Editing the configuration The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance. To get started, the parts you need to change are all in the "About your deployment" section. We've added comments to that file to highlight what each setting needs to be, but the main ones are: - `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. - `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. - `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. - `env/clear/BASE_URL`: The public URL of your Fizzy instance (e.g., `https://fizzy.example.com`). Used when generating links. - `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. - `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. - `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account). Fizzy also requires a few environment variables to be set up, some of which contain secrets. The simplest way to do this is to put them in a file called `.kamal/secrets`. Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally. If you use a password manager like 1Password, you can also opt to keep your secrets there instead. Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that. To store your secrets, create the file `.kamal/secrets` and enter something like the following: ```ini SECRET_KEY_BASE=12345 VAPID_PUBLIC_KEY=something VAPID_PRIVATE_KEY=somethingelse SMTP_USERNAME=email-provider-username SMTP_PASSWORD=email-provider-password ``` The values you enter here will be specific to you, and you can get or create them as follows: - `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. - `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. - `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: ```sh bin/rails c ``` And then run the following to create a new pair of keys: ```ruby vapid_key = WebPush.generate_key puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" ``` Once you've made all those changes, commit them to your fork so they're saved. ### Deploy Fizzy! You can now do your first deploy by running: ```sh bin/kamal setup ``` This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running. After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run: ```sh bin/kamal deploy ``` ### Configuring file storage (Active Storage) Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. To use the included `s3` service, set: - `ACTIVE_STORAGE_SERVICE=s3` - `S3_ACCESS_KEY_ID` - `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) - `S3_REGION` (defaults to `us-east-1`) - `S3_SECRET_ACCESS_KEY` - `CSP_CONNECT_SRC` Optional for S3-compatible endpoints: - `S3_ENDPOINT` - `S3_FORCE_PATH_STYLE=true` - `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) - `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) ================================================ FILE: lib/action_pack/passkey/challenges_controller.rb ================================================ # = Action Pack Passkey Challenges Controller # # Generates fresh WebAuthn challenges for passkey ceremonies. The companion # JavaScript calls this endpoint before initiating a registration or # authentication ceremony so that the challenge is issued just-in-time rather # than embedded in the initial page load. # # The generated challenge is stored in an encrypted, HTTP-only, same-site # cookie and simultaneously returned in the JSON response body. The cookie is # consumed by ActionPack::Passkey::Request on the subsequent form submission. # # == Route # # By default mounted at +/rails/action_pack/passkey/challenge+ (configurable # via +config.action_pack.passkey.routes_prefix+). # class ActionPack::Passkey::ChallengesController < ActionController::Base COOKIE_NAME = :action_pack_passkey_challenge include ActionPack::Passkey::Request # Generates a fresh challenge, stores it in an encrypted cookie, and returns # it as JSON. The cookie is consumed on the next passkey form submission. def create challenge = create_passkey_challenge cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? } render json: { challenge: challenge } end private def create_passkey_challenge ActionPack::WebAuthn::PublicKeyCredential::Options.new( challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration ).challenge end end ================================================ FILE: lib/action_pack/passkey/form_helper.rb ================================================ # View helpers for rendering passkey forms and meta tags. # # Include this module in your helper or ApplicationHelper to get access to: # # - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a # tag containing the JSON-serialized WebAuthn options for the browser credential API. # - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony. # - +passkey_sign_in_button+ — render a form with hidden fields for the authentication # ceremony. module ActionPack::Passkey::FormHelper # Renders ++ tags containing JSON-serialized creation options and the challenge endpoint # URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call # +navigator.credentials.create()+. def passkey_creation_options_meta_tag(creation_options, challenge_url: nil) passkey_challenge_url_meta_tag(challenge_url: challenge_url) + tag.meta(name: "passkey-creation-options", content: creation_options.to_json) end # Renders ++ tags containing JSON-serialized request options and the challenge endpoint # URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to # call +navigator.credentials.get()+. def passkey_request_options_meta_tag(request_options, challenge_url: nil) passkey_challenge_url_meta_tag(challenge_url: challenge_url) + tag.meta(name: "passkey-request-options", content: request_options.to_json) end # Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to # +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and # +transports+ — populated by the Stimulus controller after the browser credential API # resolves. Accepts a +label+ string or a block for button content. # # Options: # - +param+: the form parameter namespace (default: +:passkey+) # - +form+: additional HTML attributes for the +
    + tag # - All other options are passed to the + <% end %> <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: saas/app/views/signup/new.html.erb ================================================ <% @page_title = "Sign up for Fizzy" %>
    ">

    Sign up

    <%= form_with model: @signup, url: saas.signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %> <%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %> <% if @signup.errors.any? %>
      <% @signup.errors.full_messages.each do |message| %>
    • <%= message %>
    • <% end %>
    <% end %> <% end %>
    <% content_for :footer do %> <%= render "sessions/footer" %> <% end %> ================================================ FILE: saas/bin/broadcast_to_bc ================================================ #!/usr/bin/env bash url=$(op read "op://Deploy/Deploy Chatbot/url" --account 23QPQDKZC5BKBIIG7UGT5GR5RM) curl -s -d content="[Fizzy] ${1}" "${url}" ================================================ FILE: saas/bin/setup ================================================ #!/usr/bin/env bash set -eo pipefail # SaaS-specific setup tasks # This script is called from the main Fizzy bin/setup when SAAS is enabled if [ -e tmp/minio-dev.txt ]; then step "Starting Docker services" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup minio" step "Configuring MinIO" bin/minio-setup fi step "Starting mysql" bash -c "[ -d ~/Work/basecamp/docker-dev ] && git -C ~/Work/basecamp/docker-dev pull || gh repo clone basecamp/docker-dev ~/Work/basecamp/docker-dev && ~/Work/basecamp/docker-dev/setup mysql80" ================================================ FILE: saas/config/database.yml ================================================ <% if ENV["MIGRATE"].present? mysql_app_user_key = "MYSQL_ALTER_USER" mysql_app_password_key = "MYSQL_ALTER_PASSWORD" max_execution_time_ms = 0 # No limit else mysql_app_user_key = "MYSQL_APP_USER" mysql_app_password_key = "MYSQL_APP_PASSWORD" max_execution_time_ms = 5_000 end mysql_app_user = ENV[mysql_app_user_key] mysql_app_password = ENV[mysql_app_password_key] gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir %> default: &default adapter: trilogy host: <%= ENV.fetch "FIZZY_DB_HOST", "127.0.0.1" %> port: <%= ENV.fetch "FIZZY_DB_PORT", 3306 %> pool: 50 timeout: 5000 variables: transaction_isolation: READ-COMMITTED max_execution_time: <%= max_execution_time_ms %> development: primary: <<: *default database: fizzy_development port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: <<: *default database: fizzy_development port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: true cable: <<: *default database: development_cable port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: db/cable_migrate cache: <<: *default database: development_cache port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: db/cache_migrate queue: <<: *default database: development_queue port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: db/queue_migrate saas: <<: *default database: fizzy_saas_development port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: <%= File.join(gem_path, "db", "migrate") %> schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: primary: <<: *default database: fizzy_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: <<: *default database: fizzy_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> replica: true saas: <<: *default database: fizzy_saas_test port: <%= ENV.fetch "FIZZY_DB_PORT", 33380 %> migrations_paths: <%= File.join(gem_path, "db", "migrate") %> schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> production: &production primary: <<: *default database: fizzy_production host: <%= ENV["MYSQL_DATABASE_HOST"] %> username: <%= mysql_app_user %> password: <%= mysql_app_password %> replica: <<: *default database: fizzy_production host: <%= ENV["MYSQL_DATABASE_REPLICA_HOST"] %> username: <%= ENV["MYSQL_READONLY_USER"] %> password: <%= ENV["MYSQL_READONLY_PASSWORD"] %> replica: true cable: <<: *default database: fizzy_solidcable_production host: <%= ENV["MYSQL_SOLID_CABLE_HOST"] %> username: <%= mysql_app_user %> password: <%= mysql_app_password %> migrations_paths: db/cable_migrate queue: <<: *default database: fizzy_solidqueue_production host: <%= ENV["MYSQL_SOLID_QUEUE_HOST"] %> username: <%= mysql_app_user %> password: <%= mysql_app_password %> migrations_paths: db/queue_migrate cache: <<: *default database: fizzy_solidcache_production host: <%= ENV["MYSQL_SOLID_CACHE_HOST"] %> username: <%= mysql_app_user %> password: <%= mysql_app_password %> migrations_paths: db/cache_migrate saas: <<: *default database: fizzy_saas_production host: <%= ENV["MYSQL_DATABASE_HOST"] %> username: <%= mysql_app_user %> password: <%= mysql_app_password %> migrations_paths: <%= File.join(gem_path, "db", "migrate") %> schema_dump: <%= File.join(gem_path, "db", "saas_schema.rb") %> beta: *production staging: *production ================================================ FILE: saas/config/deploy.beta.yml ================================================ <% raise "The BETA_NUMBER environment variable must be given" unless ENV["BETA_NUMBER"] @data = { "1" => { "hosts" => { "web" => ["fizzy-beta-app-101.df-iad-int.37signals.com"], "jobs" => ["fizzy-beta-jobs-101.df-iad-int.37signals.com"], "lb" => "fizzy-beta-lb-101.df-iad-int.37signals.com" }, "dbs" => { "solidqueue" => "fizzy-beta-solidqueue-db-101.df-iad-int.37signals.com" } }, "2" => { "hosts" => { "web" => ["fizzy-beta-app-102.df-iad-int.37signals.com"], "jobs" => ["fizzy-beta-jobs-102.df-iad-int.37signals.com"], "lb" => "fizzy-beta-lb-102.df-iad-int.37signals.com" }, "dbs" => { "solidqueue" => "fizzy-beta-solidqueue-db-102.df-iad-int.37signals.com" } }, "3" => { "hosts" => { "web" => ["fizzy-beta-app-103.df-iad-int.37signals.com"], "jobs" => ["fizzy-beta-jobs-103.df-iad-int.37signals.com"], "lb" => "fizzy-beta-lb-103.df-iad-int.37signals.com" }, "dbs" => { "solidqueue" => "fizzy-beta-solidqueue-db-103.df-iad-int.37signals.com" } }, "4" => { "hosts" => { "web" => ["fizzy-beta-app-104.df-iad-int.37signals.com"], "jobs" => ["fizzy-beta-jobs-104.df-iad-int.37signals.com"], "lb" => "fizzy-beta-lb-104.df-iad-int.37signals.com" }, "dbs" => { "solidqueue" => "fizzy-beta-solidqueue-db-104.df-iad-int.37signals.com" } } } @beta_number = ENV["BETA_NUMBER"] raise "Beta #{@beta_number} doesn't appear to be defined" unless @data[@beta_number] @web_hosts = @data[@beta_number]["hosts"]["web"] @job_hosts = @data[@beta_number]["hosts"]["jobs"] @lb_host = @data[@beta_number]["hosts"]["lb"] @solidqueue_db = @data[@beta_number]["dbs"]["solidqueue"] %> retain_containers: 1 servers: web: hosts: - <%= @web_hosts[0] %>: df_iad labels: otel_scrape_enabled: true jobs: hosts: - <%= @job_hosts[0] %>: df_iad labels: otel_scrape_enabled: true proxy: ssl: false ssh: user: app env: clear: APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com CACHE_NAMESPACE: <%= @beta_number %> RAILS_ENV: beta RAILS_LOG_LEVEL: fatal # suppress unstructured log lines MYSQL_DATABASE_HOST: fizzy-mysql-primary MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary MYSQL_SOLID_QUEUE_HOST: <%= @solidqueue_db %> MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101.df-iad-int.37signals.com secret: - RAILS_MASTER_KEY - MYSQL_ALTER_PASSWORD - MYSQL_ALTER_USER - MYSQL_APP_PASSWORD - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY - ACTIVE_STORAGE_ACCESS_KEY_ID - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET - APNS_KEY_ID - APNS_ENCRYPTION_KEY_B64 - FCM_ENCRYPTION_KEY_B64 tags: df_iad: PRIMARY_DATACENTER: true accessories: load-balancer: image: basecamp/kamal-proxy:lb host: <%= @lb_host %> labels: otel_role: load-balancer otel_service: fizzy-load-balancer otel_scrape_enabled: true options: publish: - 80:80 - 443:443 volumes: - load-balancer:/home/kamal-proxy/.config/kamal-proxy ================================================ FILE: saas/config/deploy.beta1.yml ================================================ <% ENV["BETA_NUMBER"] = "1" %> <%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %> ================================================ FILE: saas/config/deploy.beta2.yml ================================================ <% ENV["BETA_NUMBER"] = "2" %> <%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %> ================================================ FILE: saas/config/deploy.beta3.yml ================================================ <% ENV["BETA_NUMBER"] = "3" %> <%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %> ================================================ FILE: saas/config/deploy.beta4.yml ================================================ <% ENV["BETA_NUMBER"] = "4" %> <%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %> ================================================ FILE: saas/config/deploy.production.yml ================================================ retain_containers: 2 servers: web: hosts: - fizzy-app-01.sc-chi-int.37signals.com: sc_chi - fizzy-app-02.sc-chi-int.37signals.com: sc_chi - fizzy-app-101.df-iad-int.37signals.com: df_iad - fizzy-app-102.df-iad-int.37signals.com: df_iad - fizzy-app-401.df-ams-int.37signals.com: df_ams - fizzy-app-402.df-ams-int.37signals.com: df_ams labels: otel_scrape_enabled: true jobs: hosts: - fizzy-jobs-101.df-iad-int.37signals.com: df_iad - fizzy-jobs-102.df-iad-int.37signals.com: df_iad labels: otel_scrape_enabled: true proxy: ssl: false ssh: user: app env: clear: RAILS_ENV: production RAILS_LOG_LEVEL: fatal # suppress unstructured log lines MYSQL_DATABASE_HOST: fizzy-mysql-primary MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary secret: - RAILS_MASTER_KEY - MYSQL_ALTER_PASSWORD - MYSQL_ALTER_USER - MYSQL_APP_PASSWORD - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY - ACTIVE_STORAGE_ACCESS_KEY_ID - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET - APNS_KEY_ID - APNS_ENCRYPTION_KEY_B64 - FCM_ENCRYPTION_KEY_B64 tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com df_iad: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-101.df-iad-int.37signals.com PRIMARY_DATACENTER: true df_ams: MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-401.df-ams-int.37signals.com accessories: load-balancer: image: basecamp/kamal-proxy:lb hosts: - fizzy-lb-101.df-iad-int.37signals.com - fizzy-lb-102.df-iad-int.37signals.com - fizzy-lb-01.sc-chi-int.37signals.com - fizzy-lb-02.sc-chi-int.37signals.com - fizzy-lb-401.df-ams-int.37signals.com - fizzy-lb-402.df-ams-int.37signals.com labels: otel_role: load-balancer otel_service: fizzy-load-balancer otel_scrape_enabled: true options: publish: - 80:80 - 443:443 # NFS mount for certificates # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-production-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" volumes: - load-balancer:/home/kamal-proxy/.config/kamal-proxy ================================================ FILE: saas/config/deploy.staging.yml ================================================ retain_containers: 1 servers: web: hosts: - fizzy-staging-app-101.df-iad-int.37signals.com: df_iad - fizzy-staging-app-102.df-iad-int.37signals.com: df_iad - fizzy-staging-app-01.sc-chi-int.37signals.com: sc_chi - fizzy-staging-app-02.sc-chi-int.37signals.com: sc_chi - fizzy-staging-app-401.df-ams-int.37signals.com: df_ams - fizzy-staging-app-402.df-ams-int.37signals.com: df_ams labels: otel_scrape_enabled: true jobs: hosts: - fizzy-staging-jobs-101.df-iad-int.37signals.com: df_iad - fizzy-staging-jobs-102.df-iad-int.37signals.com: df_iad labels: otel_scrape_enabled: true proxy: ssl: false ssh: user: app env: clear: RAILS_ENV: staging RAILS_LOG_LEVEL: fatal # suppress unstructured log lines MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary MYSQL_SOLID_QUEUE_HOST: fizzy-staging-mysql-primary secret: - RAILS_MASTER_KEY - MYSQL_ALTER_PASSWORD - MYSQL_ALTER_USER - MYSQL_APP_PASSWORD - MYSQL_APP_USER - MYSQL_READONLY_PASSWORD - MYSQL_READONLY_USER - SECRET_KEY_BASE - VAPID_PUBLIC_KEY - VAPID_PRIVATE_KEY - ACTIVE_STORAGE_ACCESS_KEY_ID - ACTIVE_STORAGE_SECRET_ACCESS_KEY - QUEENBEE_API_TOKEN - SIGNAL_ID_SECRET - SENTRY_DSN - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT - STRIPE_MONTHLY_V1_PRICE_ID - STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID - STRIPE_SECRET_KEY - STRIPE_WEBHOOK_SECRET tags: sc_chi: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com df_iad: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-101.df-iad-int.37signals.com PRIMARY_DATACENTER: true df_ams: MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-401.df-ams-int.37signals.com accessories: load-balancer: image: basecamp/kamal-proxy:lb hosts: - fizzy-staging-lb-01.sc-chi-int.37signals.com - fizzy-staging-lb-101.df-iad-int.37signals.com - fizzy-staging-lb-401.df-ams-int.37signals.com labels: otel_role: load-balancer otel_service: fizzy-load-balancer otel_scrape_enabled: true options: publish: - 80:80 - 443:443 # NFS mount for certificates # See https://3.basecamp.com/2914079/buckets/37331921/todos/9180260061 mount: type=volume,src=certificates,dst=/certificates,volume-driver=local,volume-opt=type=nfs,volume-opt=device=:/fizzy-staging-certificates,"volume-opt=o=addr=purestorage.sc-chi-int.37signals.com,nfsvers=3,rw,noatime,nconnect=8,soft,timeo=30,retrans=2" volumes: - load-balancer:/home/kamal-proxy/.config/kamal-proxy ================================================ FILE: saas/config/deploy.yml ================================================ service: fizzy image: basecamp/fizzy asset_path: /rails/public/assets hooks_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal", "hooks") %> secrets_path: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, ".kamal/secrets") %> servers: jobs: cmd: bin/jobs volumes: - fizzy:/rails/storage proxy: ssl: true registry: server: registry.37signals.com username: robot$harbor-bot password: - BASECAMP_REGISTRY_PASSWORD builder: arch: amd64 dockerfile: <%= File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "Dockerfile") %> secrets: - GITHUB_TOKEN remote: ssh://app@docker-builder-102 local: <%= ENV.fetch("KAMAL_BUILDER_LOCAL", "true") %> aliases: console: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> "bin/rails console" ssh: app exec -i --reuse -e CONSOLE_USER:<%= ENV["USER"] %> /bin/bash ================================================ FILE: saas/config/environments/beta.rb ================================================ require_relative "production" Rails.application.configure do config.action_mailer.smtp_settings[:domain] = ENV.fetch("APP_FQDN", "fizzy-beta.com") config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" config.action_mailer.default_url_options = { host: ENV.fetch("APP_FQDN", "fizzy-beta.com"), protocol: "https" } config.action_controller.default_url_options = { host: ENV.fetch("APP_FQDN", "fizzy-beta.com"), protocol: "https" } end ================================================ FILE: saas/config/environments/development.rb ================================================ Rails.application.configure do # SaaS version of Fizzy is multi-tenanted config.x.multi_tenant.enabled = true if Rails.root.join("tmp/structured-logging.txt").exist? config.structured_logging.logger = ActiveSupport::Logger.new("log/structured-development.log") end if Rails.root.join("tmp/solid-queue.txt").exist? config.active_job.queue_adapter = :solid_queue config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } } end end ================================================ FILE: saas/config/environments/production.rb ================================================ Rails.application.configure do config.active_storage.service = :purestorage # Enable structured logging config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT) config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" } config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false } # SaaS version of Fizzy is multi-tenanted config.x.multi_tenant.enabled = true # Content Security Policy config.x.content_security_policy.report_only = false config.x.content_security_policy.report_uri = "https://o33603.ingest.us.sentry.io/api/4510481339187200/security/?sentry_key=9f126ba30d5f703451a13a2929bb5a10" # gitleaks:allow (public DSN for CSP reports) config.x.content_security_policy.script_src = "https://challenges.cloudflare.com" config.x.content_security_policy.frame_src = "https://challenges.cloudflare.com" config.x.content_security_policy.connect_src = "https://storage.basecamp.com" end ================================================ FILE: saas/config/environments/staging.rb ================================================ require_relative "production" Rails.application.configure do config.action_mailer.smtp_settings[:domain] = "app.fizzy-staging.com" config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging" config.action_mailer.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } config.action_controller.default_url_options = { host: "app.fizzy-staging.com", protocol: "https" } end ================================================ FILE: saas/config/push.yml ================================================ shared: apple: key_id: <%= ENV["APNS_KEY_ID"] %> encryption_key: <%= Base64.decode64(ENV["APNS_ENCRYPTION_KEY_B64"] || "").dump %> team_id: <%= ENV["APNS_TEAM_ID"] || "2WNYUYRS7G" %> topic: <%= ENV["APNS_TOPIC"] || "do.fizzy.app.ios" %> connect_to_development_server: <%= Rails.env.local? %> google: encryption_key: <%= Base64.decode64(ENV["FCM_ENCRYPTION_KEY_B64"] || "").dump %> project_id: fizzy-a148c ================================================ FILE: saas/config/routes.rb ================================================ Fizzy::Saas::Engine.routes.draw do Queenbee.routes(self) namespace :my do resources :devices, only: [ :index, :create, :destroy ] end namespace :admin do mount Audits1984::Engine, at: "/console" get "stats", to: "stats#show" end end ================================================ FILE: saas/config/storage.yml ================================================ test: service: Disk root: <%= Rails.root.join("tmp/storage/files") %> local: service: Disk root: <%= Rails.root.join("storage", Rails.env, "files") %> devminio: service: S3 bucket: fizzy-dev-activestorage endpoint: "http://minio.localhost:39000" force_path_style: true request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support region: us-east-1 # default region required for signer access_key_id: minioadmin secret_access_key: minioadmin # We have "development", "staging", and "production" buckets configured. Note that we don't have a # "beta" bucket. (As of 2025-06-01.) <% pure_env = Rails.env.beta? ? "production" : Rails.env %> purestorage: service: S3 bucket: fizzy-<%= pure_env %>-activestorage endpoint: "https://storage.basecamp.com" ssl_verify_peer: false # FIXME: using self-signed cert internally force_path_style: true request_checksum_calculation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support response_checksum_validation: when_required # default is when_supported with CRC64NVME checksum which FlashBlade doesn't support region: us-east-1 # default region required for signer access_key_id: <%= ENV["ACTIVE_STORAGE_ACCESS_KEY_ID"] %> secret_access_key: <%= ENV["ACTIVE_STORAGE_SECRET_ACCESS_KEY"] %> ================================================ FILE: saas/db/migrate/20251202200249_create_console1984_tables.console1984.rb ================================================ # This migration comes from console1984 (originally 20210517203931) class CreateConsole1984Tables < ActiveRecord::Migration[7.0] def change create_table :console1984_sessions do |t| t.text :reason t.references :user, null: false, index: false t.timestamps t.index :created_at t.index [ :user_id, :created_at ] end create_table :console1984_users do |t| t.string :username, null: false t.timestamps t.index [:username] end create_table :console1984_commands do |t| t.text :statements t.references :sensitive_access t.references :session, null: false, index: false t.timestamps t.index [ :session_id, :created_at, :sensitive_access_id ], name: "on_session_and_sensitive_chronologically" end create_table :console1984_sensitive_accesses do |t| t.text :justification t.references :session, null: false t.timestamps end end end ================================================ FILE: saas/db/migrate/20251202205753_create_auditing_tables.audits1984.rb ================================================ # This migration comes from audits1984 (originally 20210810092639) class CreateAuditingTables < ActiveRecord::Migration[7.0] def change create_table :audits1984_audits do |t| t.integer :status, default: 0, null: false t.text :notes t.references :session, null: false t.uuid :auditor_id, null: false t.timestamps end end end ================================================ FILE: saas/db/migrate/20251203144630_create_account_subscriptions.rb ================================================ class CreateAccountSubscriptions < ActiveRecord::Migration[8.2] def change create_table :account_subscriptions, id: :uuid do |t| t.references :account, null: false, type: :uuid, index: true t.string :plan_key t.string :stripe_customer_id, null: false, index: { unique: true } t.string :stripe_subscription_id, index: { unique: true } t.string :status t.datetime :current_period_end t.datetime :cancel_at t.timestamps end end end ================================================ FILE: saas/db/migrate/20251215140000_create_account_overridden_limits.rb ================================================ class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2] def change create_table :account_overridden_limits, id: :uuid do |t| t.references :account, null: false, type: :uuid, index: { unique: true } t.integer :card_count t.timestamps end end end ================================================ FILE: saas/db/migrate/20251215160000_create_account_billing_waivers.rb ================================================ class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2] def change create_table :account_billing_waivers, id: :uuid do |t| t.references :account, null: false, type: :uuid, index: { unique: true } t.timestamps end end end ================================================ FILE: saas/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb ================================================ class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2] def change add_column :account_subscriptions, :next_amount_due_in_cents, :integer end end ================================================ FILE: saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb ================================================ class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2] def change add_column :account_overridden_limits, :bytes_used, :bigint end end ================================================ FILE: saas/db/migrate/20260114203313_create_action_push_native_devices.rb ================================================ class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0] def change create_table :action_push_native_devices do |t| t.string :name t.string :platform, null: false t.string :token, null: false t.belongs_to :owner, polymorphic: true, type: :uuid, index: false t.belongs_to :session, type: :uuid t.timestamps end add_index :action_push_native_devices, [ :owner_type, :owner_id, :token ], unique: true end end ================================================ FILE: saas/db/migrate/20260126230838_create_auditor_tokens.audits1984.rb ================================================ # This migration comes from audits1984 (originally 20260126000000) class CreateAuditorTokens < ActiveRecord::Migration[7.0] def change create_table :audits1984_auditor_tokens do |t| t.uuid :auditor_id, null: false, index: { unique: true } t.string :token_digest, null: false t.datetime :expires_at, null: false t.timestamps t.index :token_digest, unique: true end end end ================================================ FILE: saas/db/migrate/20260317000000_drop_billing_tables.rb ================================================ class DropBillingTables < ActiveRecord::Migration[8.2] def change drop_table :account_subscriptions drop_table :account_overridden_limits drop_table :account_billing_waivers end end ================================================ FILE: saas/db/migrate/20260319142914_create_account_storage_exceptions.rb ================================================ class CreateAccountStorageExceptions < ActiveRecord::Migration[8.2] def change create_table :account_storage_exceptions, id: :uuid do |t| t.references :account, null: false, type: :uuid, index: { unique: true } t.bigint :bytes_allowed, null: false t.timestamps end end end ================================================ FILE: saas/db/saas_schema.rb ================================================ # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # # This file is the source Rails uses to define your schema when running `bin/rails # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 2026_03_19_142914) do create_table "account_storage_exceptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.bigint "bytes_allowed", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_account_storage_exceptions_on_account_id", unique: true end create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "name" t.uuid "owner_id" t.string "owner_type" t.string "platform", null: false t.uuid "session_id" t.string "token", null: false t.datetime "updated_at", null: false t.index ["owner_type", "owner_id", "token"], name: "idx_on_owner_type_owner_id_token_95a4008c64", unique: true t.index ["session_id"], name: "index_action_push_native_devices_on_session_id" end create_table "audits1984_auditor_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "auditor_id", null: false t.datetime "created_at", null: false t.datetime "expires_at", null: false t.string "token_digest", null: false t.datetime "updated_at", null: false t.index ["auditor_id"], name: "index_audits1984_auditor_tokens_on_auditor_id", unique: true t.index ["token_digest"], name: "index_audits1984_auditor_tokens_on_token_digest", unique: true end create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "auditor_id", null: false t.datetime "created_at", null: false t.text "notes" t.bigint "session_id", null: false t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["session_id"], name: "index_audits1984_audits_on_session_id" end create_table "console1984_commands", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "sensitive_access_id" t.bigint "session_id", null: false t.text "statements" t.datetime "updated_at", null: false t.index ["sensitive_access_id"], name: "index_console1984_commands_on_sensitive_access_id" t.index ["session_id", "created_at", "sensitive_access_id"], name: "on_session_and_sensitive_chronologically" end create_table "console1984_sensitive_accesses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.text "justification" t.bigint "session_id", null: false t.datetime "updated_at", null: false t.index ["session_id"], name: "index_console1984_sensitive_accesses_on_session_id" end create_table "console1984_sessions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.text "reason" t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["created_at"], name: "index_console1984_sessions_on_created_at" t.index ["user_id", "created_at"], name: "index_console1984_sessions_on_user_id_and_created_at" end create_table "console1984_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "username", null: false t.index ["username"], name: "index_console1984_users_on_username" end end ================================================ FILE: saas/exe/push-dev ================================================ #!/usr/bin/env ruby # # Fetches push notification credentials from 1Password for development. # Uses the same 1Password items as production (Deploy/Fizzy Production). # # Usage: eval "$(bundle exec push-dev)" OP_ACCOUNT = "23QPQDKZC5BKBIIG7UGT5GR5RM" OP_VAULT = "Deploy" OP_ITEM = "Fizzy" def op_read(field) `op read "op://#{OP_VAULT}/#{OP_ITEM}/Production/#{field}" --account #{OP_ACCOUNT} 2>/dev/null`.strip end apns_key_id = op_read("APNS_KEY_ID") apns_encryption_key_b64 = op_read("APNS_ENCRYPTION_KEY_B64") fcm_encryption_key_b64 = op_read("FCM_ENCRYPTION_KEY_B64") if apns_key_id.empty? || apns_encryption_key_b64.empty? warn "Error: Could not fetch APNs credentials from 1Password" warn "Make sure you're signed in: op signin --account #{OP_ACCOUNT}" exit 1 end if fcm_encryption_key_b64.empty? warn "Warning: Could not fetch FCM credentials from 1Password" warn "Android push notifications will not work" end puts %Q(export APNS_KEY_ID="#{apns_key_id}") puts %Q(export APNS_TEAM_ID="#{apns_team_id}") puts %Q(export APNS_ENCRYPTION_KEY_B64="#{apns_encryption_key_b64}") puts %Q(export FCM_ENCRYPTION_KEY_B64="#{fcm_encryption_key_b64}") puts %Q(export ENABLE_NATIVE_PUSH="true") warn "" warn "Push notification credentials loaded for development" warn " APNs Key ID: #{apns_key_id}" warn " APNs: #{apns_encryption_key_b64.empty? ? "not configured" : "configured"}" warn " FCM: #{fcm_encryption_key_b64.empty? ? "not configured" : "configured"}" warn " Native push: enabled" ================================================ FILE: saas/fizzy-saas.gemspec ================================================ require_relative "lib/fizzy/saas/version" Gem::Specification.new do |spec| spec.name = "fizzy-saas" spec.version = Fizzy::Saas::VERSION spec.authors = [ "Mike Dalessio" ] spec.email = [ "mike@37signals.com" ] spec.homepage = "https://github.com/basecamp/fizzy-saas" spec.summary = "37signals SaaS companion for Fizzy" spec.description = "Rails engine that bundles with Fizzy to offer the hosted version at https://app.fizzy.do" spec.license = "O'Saasy" # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host" # to allow pushing to a single host or delete this section to allow pushing to any host. spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas" spec.files = Dir.chdir(File.expand_path(__dir__)) do Dir["{app,config,db,lib,exe}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"] end spec.bindir = "exe" spec.executables = [ "push-dev", "stripe-dev" ] spec.add_dependency "rails", ">= 8.1.0.beta1" spec.add_dependency "queenbee" spec.add_dependency "rails_structured_logging" spec.add_dependency "sentry-ruby" spec.add_dependency "sentry-rails" spec.add_dependency "yabeda" spec.add_dependency "yabeda-actioncable" spec.add_dependency "yabeda-activejob" spec.add_dependency "yabeda-gc" spec.add_dependency "yabeda-http_requests" spec.add_dependency "yabeda-prometheus-mmap" spec.add_dependency "yabeda-puma-plugin" spec.add_dependency "yabeda-rails", ">= 0.10" spec.add_dependency "prometheus-client-mmap", "~> 1.4.0" spec.add_dependency "console1984" spec.add_dependency "audits1984" end ================================================ FILE: saas/lib/fizzy/saas/authorization.rb ================================================ module Fizzy module Saas module Authorization module Controller extend ActiveSupport::Concern included do before_action :ensure_only_employees_can_access_non_production_remote_environments, if: :authenticated? end private def ensure_only_employees_can_access_non_production_remote_environments head :forbidden if Rails.env.staging? && !Current.identity.employee? end end module Identity extend ActiveSupport::Concern EMPLOYEE_DOMAINS = [ "@37signals.com", "@basecamp.com" ].freeze def employee? email_address.end_with?(*EMPLOYEE_DOMAINS) end end end end end ================================================ FILE: saas/lib/fizzy/saas/engine.rb ================================================ require_relative "transaction_pinning" require_relative "true_client_ip" require_relative "signup" require_relative "authorization" require_relative "gvl_instrumentation" require_relative "../../rails_ext/active_record_tasks_database_tasks.rb" module Fizzy module Saas class Engine < ::Rails::Engine # moved from config/initializers/queenbee.rb Queenbee.host_app = Fizzy # Configure ActionPushNative to use the saas database ActiveSupport.on_load(:action_push_native_record) do connects_to database: { writing: :saas, reading: :saas } end initializer "fizzy_saas.assets" do |app| app.config.assets.paths << root.join("app/assets/stylesheets") end initializer "fizzy_saas.public_files" do |app| app.middleware.insert_after ActionDispatch::Static, ActionDispatch::Static, root.join("public").to_s, headers: app.config.public_file_server.headers end initializer "fizzy_saas.push_config", after: "action_push_native.config" do |app| app.paths["config/push"].unshift(root.join("config/push.yml").to_s) end initializer "fizzy.saas.mount" do |app| app.routes.append do mount Fizzy::Saas::Engine => "/", as: "saas" end end initializer "fizzy_saas.transaction_pinning" do |app| app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware) end initializer "fizzy_saas.true_client_ip" do |app| app.config.middleware.insert_before ActionDispatch::RemoteIp, TrackTrueClientIp end initializer "fizzy_saas.gvl_instrumentation" do |app| app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation) end initializer "fizzy_saas.solid_queue" do SolidQueue.on_start do Process.warmup Yabeda::Prometheus::Exporter.start_metrics_server! end end initializer "fizzy_saas.logging.session" do |app| ActiveSupport.on_load(:action_controller_base) do before_action do if Current.identity.present? logger.struct(authentication: { identity: { id: Current.identity.id } }) end if Current.account.present? logger.struct(account: { queenbee_id: Current.account.external_account_id }) end end end end # Load test mocks automatically in test environment initializer "fizzy_saas.test_mocks", after: :load_config_initializers do if Rails.env.test? require_relative "testing" end end initializer "fizzy_saas.sentry" do if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank? Sentry.init do |config| config.dsn = ENV["SENTRY_DSN"] config.breadcrumbs_logger = %i[ active_support_logger http_logger ] config.send_default_pii = false config.release = ENV["KAMAL_VERSION"] config.excluded_exceptions += [ "ActiveRecord::ConcurrentMigrationError" ] # Receive Rails.error.report and retry_on/discard_on report: true config.rails.register_error_subscriber = true end end end initializer "fizzy_saas.yabeda" do require "prometheus/client/support/puma" Prometheus::Client.configuration.logger = Rails.logger Prometheus::Client.configuration.pid_provider = Prometheus::Client::Support::Puma.method(:worker_pid_provider) Yabeda::Rails.config.controller_name_case = :camel Yabeda::Rails.config.ignore_actions = %w[ Rails::HealthController#show ] Yabeda::ActiveJob.install! require "yabeda/solid_queue" Yabeda::SolidQueue.install! Yabeda::ActionCable.configure do |config| config.channel_class_name = "ActionCable::Channel::Base" end require "yabeda/gvl" Yabeda::GVL.install! require_relative "metrics" end config.before_initialize do config.console1984.protected_environments = %i[ production beta staging ] config.console1984.ask_for_username_if_empty = true config.console1984.base_record_class = "::SaasRecord" config.console1984.incinerate_after = 60.days config.audits1984.base_controller_class = "::Admin::AuditsController" config.audits1984.auditor_class = "::Identity" config.audits1984.auditor_name_attribute = :email_address if config.console1984.protected_environments.include?(Rails.env.to_sym) config.active_record.encryption.primary_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY") config.active_record.encryption.deterministic_key = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY") config.active_record.encryption.key_derivation_salt = ENV.fetch("ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT") end end config.to_prepare do ::Account.include Account::StorageLimited ::Identity.include Authorization::Identity, Identity::Devices ::Session.include Session::Devices ::Signup.prepend Signup ApplicationController.include Authorization::Controller CardsController.include(Card::StorageLimited::Creation) Cards::CommentsController.include(Card::StorageLimited::Commenting) Cards::PublishesController.include(Card::StorageLimited::Publishing) Notification.register_push_target(:native) Queenbee::Subscription.short_names = Subscription::SHORT_NAMES # Default to local dev QB token if not set Queenbee::ApiToken.token = ENV.fetch("QUEENBEE_API_TOKEN") { "69a4cfb8705913e6323f7b4c0c0cff9bd8df37da532f4375b85e9655b8100bb023591b48d308205092aa0a04dd28cb6c62d6798364a6f44cc1e675814eb148a1" } # gitleaks:allow development-only token Subscription::SHORT_NAMES.each do |short_name| const_name = "#{short_name}Subscription" ::Object.send(:remove_const, const_name) if ::Object.const_defined?(const_name) ::Object.const_set const_name, Subscription.const_get(short_name, false) end end end end end ================================================ FILE: saas/lib/fizzy/saas/gvl_instrumentation.rb ================================================ module Fizzy module Saas class GvlInstrumentation def initialize(app) @app = app end def call(env) GVLTools::LocalTimer.enable before = GVLTools::LocalTimer.monotonic_time result = @app.call(env) gvl_wait_ns = GVLTools::LocalTimer.monotonic_time - before Yabeda.gvl.request_wait.measure({}, gvl_wait_ns / 1_000_000_000.0) result ensure GVLTools::LocalTimer.disable end end end end ================================================ FILE: saas/lib/fizzy/saas/metrics.rb ================================================ Yabeda.configure do SHORT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5 ] group :fizzy do counter :replica_stale, comment: "Number of requests served from a stale replica" histogram :replica_wait, unit: :seconds, comment: "Time spent waiting for replica to catch up with transaction", buckets: SHORT_HISTOGRAM_BUCKETS end end ================================================ FILE: saas/lib/fizzy/saas/signup.rb ================================================ module Fizzy module Saas module Signup extend ActiveSupport::Concern included do attr_reader :queenbee_account end private def create_tenant @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) @queenbee_account.id.to_s end def handle_account_creation_error(error) @queenbee_account&.cancel end def queenbee_account_attributes {}.tap do |attributes| attributes[:product_name] = "fizzy" attributes[:name] = generate_account_name attributes[:owner_name] = full_name attributes[:owner_email] = email_address attributes[:trial] = true attributes[:subscription] = subscription_attributes attributes[:remote_request] = request_attributes # # TODO: Terms of Service # attributes[:terms_of_service] = true # We've confirmed the email attributes[:auto_allow] = true # Tell Queenbee to skip the request to create a local account. We've created it ourselves. attributes[:skip_remote] = true end end end end end ================================================ FILE: saas/lib/fizzy/saas/testing.rb ================================================ require "queenbee/testing/mocks" Queenbee::Remote::Account.class_eval do # because we use the account ID as the tenant name, we need it to be unique in each test to avoid # parallelized tests clobbering each other. def next_id super + Random.rand(1000000) end end # Add engine fixtures to the test fixture paths module Fizzy::Saas::EngineFixtures def included(base) super engine_fixtures = Fizzy::Saas::Engine.root.join("test", "fixtures").to_s base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures) end end ActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures) ================================================ FILE: saas/lib/fizzy/saas/transaction_pinning.rb ================================================ module TransactionPinning class Middleware SESSION_KEY = :last_txn DEFAULT_MAX_WAIT = 0.25 def initialize(app) @app = app @timeout = Rails.application.config.x.transaction_pinning&.timeout&.to_f || DEFAULT_MAX_WAIT end def call(env) request = ActionDispatch::Request.new(env) replica_metrics = {} if ApplicationRecord.current_role == :reading wait_for_replica_catchup(request, replica_metrics) end status, headers, body = @app.call(env) headers.merge!(replica_metrics.transform_values(&:to_s)) if ApplicationRecord.current_role == :writing capture_transaction_id(request) end [ status, headers, body ] end private def wait_for_replica_catchup(request, replica_metrics) if last_txn = request.session[SESSION_KEY].presence has_transaction = tracking_replica_wait_time(replica_metrics) do replica_has_transaction(last_txn) end unless has_transaction Yabeda.fizzy.replica_stale.increment replica_metrics["X-Replica-Stale"] = true end end end def capture_transaction_id(request) request.session[SESSION_KEY] = ApplicationRecord.connection.show_variable("global.gtid_executed") end def replica_has_transaction(txn) sql = ApplicationRecord.sanitize_sql_array([ "SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?)", txn, @timeout ]) ApplicationRecord.connection.select_value(sql) == 0 rescue => e Sentry.capture_exception(e, extra: { gtid: txn }) true # Treat as if we're up to date, since we don't know end def tracking_replica_wait_time(replica_metrics) started_at = Time.current Yabeda.fizzy.replica_wait.measure do yield end.tap do replica_metrics["X-Replica-Wait"] = Time.current - started_at end end end end ================================================ FILE: saas/lib/fizzy/saas/true_client_ip.rb ================================================ # # Cloudflare sets a True-Client-IP header, which for most 37signals apps gets copied to # X-Forwarded-For by an iRule on the F5 load balancers: # # https://github.com/basecamp/f5-tf/blob/1543f7bfa3961a79e397f80cf041d75567f1b2f8/ams-base/iRules/manage_x_forwarded.tcl # # However, for Fizzy the F5s are configured to do passthrough, so the header value isn't being # copied for us. Let's do that bit of work here, before Rails' RemoteIp middleware. # class TrackTrueClientIp def initialize(app) @app = app end def call(env) if env["HTTP_TRUE_CLIENT_IP"].present? env["HTTP_X_FORWARDED_FOR"] = env["HTTP_TRUE_CLIENT_IP"] end @app.call(env) end end ================================================ FILE: saas/lib/fizzy/saas/version.rb ================================================ module Fizzy module Saas VERSION = "0.1.0" end end ================================================ FILE: saas/lib/fizzy/saas.rb ================================================ require "fizzy/saas/version" require "fizzy/saas/engine" module Fizzy module Saas def self.append_test_paths engine_test_path = Engine.root.join("test") ENV["DEFAULT_TEST"] = "{#{engine_test_path},test}/**/*_test.rb" ENV["DEFAULT_TEST_EXCLUDE"] = "{#{engine_test_path},test}/{system,dummy,fixtures}/**/*_test.rb" end end end ================================================ FILE: saas/lib/rails_ext/active_record_tasks_database_tasks.rb ================================================ module ActiveRecordTasksDatabaseTasksExtension extend ActiveSupport::Concern class_methods do # proposed upstream in https://github.com/rails/rails/pull/56290 def schema_dump_path(db_config, format = db_config.schema_format) return ENV["SCHEMA"] if ENV["SCHEMA"] filename = db_config.schema_dump(format) return unless filename if Pathname.new(filename).absolute? filename else super end end end end ActiveSupport.on_load(:active_record) do ActiveRecord::Tasks::DatabaseTasks.include(ActiveRecordTasksDatabaseTasksExtension) end ================================================ FILE: saas/lib/tasks/fizzy/saas_tasks.rake ================================================ require "rake/testtask" namespace :test do desc "Run tests for fizzy-saas gem" Rake::TestTask.new(saas: :environment) do |t| t.libs << "test" t.test_files = FileList[Fizzy::Saas::Engine.root.join("test/**/*_test.rb")] t.warning = false end end ================================================ FILE: saas/lib/yabeda/gvl.rb ================================================ module Yabeda module GVL WAIT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 ] def self.install! GVLTools::GlobalTimer.enable GVLTools::WaitingThreads.enable Yabeda.configure do group :gvl do gauge :waiting_threads, comment: "Number of threads currently waiting to acquire the GVL" gauge :global_timer_total_seconds, comment: "Total time all threads spent waiting on the GVL (seconds)" histogram :request_wait, unit: :seconds, comment: "GVL wait time experienced during a single request (seconds)", buckets: WAIT_HISTOGRAM_BUCKETS end collect do gvl.waiting_threads.set({}, GVLTools::WaitingThreads.count) gvl.global_timer_total_seconds.set({}, GVLTools::GlobalTimer.monotonic_time / 1_000_000_000.0) end end end end end ================================================ FILE: saas/lib/yabeda/solid_queue.rb ================================================ module Yabeda module SolidQueue def self.install! Yabeda.configure do group :solid_queue gauge :jobs_failed_count, comment: "Number of failed jobs" gauge :jobs_unreleased_count, comment: "Number of claimed jobs that don't belong to any process" gauge :jobs_scheduled_and_delayed_count, comment: "Number of scheduled jobs that have over 5 minutes delay" gauge :recurring_tasks_count, comment: "Number of recurring jobs scheduled" gauge :recurring_tasks_delayed_count, comment: "Number of recurring jobs that haven't been enqueued within their schedule" collect do if ::SolidQueue.supervisor? solid_queue.jobs_failed_count.set({}, ::SolidQueue::FailedExecution.count) solid_queue.jobs_unreleased_count.set({}, ::SolidQueue::ClaimedExecution.where(process: nil).count) solid_queue.jobs_scheduled_and_delayed_count.set({}, ::SolidQueue::ScheduledExecution.where(scheduled_at: ..5.minutes.ago).count) solid_queue.recurring_tasks_count.set({}, ::SolidQueue::RecurringTask.count) solid_queue.recurring_tasks_delayed_count.set({}, ::SolidQueue::RecurringTask.count do |task| task.last_enqueued_time.present? && (task.previous_time - task.last_enqueued_time) > 5.minutes end) end end end end end end ================================================ FILE: saas/public/.well-known/apple-app-site-association ================================================ { "applinks": { "details": [ { "appIDs": ["2WNYUYRS7G.do.fizzy.app.ios"], "components": [ { "/": "/*", "comment": "Matches all paths." } ] } ] } } ================================================ FILE: saas/public/.well-known/assetlinks.json ================================================ [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "do.fizzy.app", "sha256_cert_fingerprints": [ "3B:17:E4:FF:F0:2E:E0:CE:D7:94:F9:9E:71:3C:A8:14:7C:FA:B7:F2:99:35:98:03:E9:E0:EB:B3:6E:12:E2:0F", "3B:4C:33:46:FD:F4:AD:4D:FE:9E:49:1D:B1:2F:EA:B1:04:33:02:97:BB:09:39:20:D4:18:69:2D:D2:9E:B5:5C" ] } } ] ================================================ FILE: saas/script/configure-lb-beta.sh ================================================ #!/usr/bin/env bash set -e # Beta 1: fizzy-beta-lb-101 -> fizzy-beta-app-101 ssh app@fizzy-beta-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=beta1.fizzy-beta.com \ --target=fizzy-beta-app-101.df-iad-int.37signals.com # Beta 2: fizzy-beta-lb-102 -> fizzy-beta-app-102 ssh app@fizzy-beta-lb-102.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=beta2.fizzy-beta.com \ --target=fizzy-beta-app-102.df-iad-int.37signals.com # Beta 3: fizzy-beta-lb-103 -> fizzy-beta-app-103 ssh app@fizzy-beta-lb-103.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=beta3.fizzy-beta.com \ --target=fizzy-beta-app-103.df-iad-int.37signals.com # Beta 4: fizzy-beta-lb-104 -> fizzy-beta-app-104 ssh app@fizzy-beta-lb-104.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=beta4.fizzy-beta.com \ --target=fizzy-beta-app-104.df-iad-int.37signals.com ================================================ FILE: saas/script/configure-lb-production.sh ================================================ #!/usr/bin/env bash set -e # fizzy-lb-101.df-iad-int.37signals.com # ssh app@fizzy-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com # fizzy-lb-102.df-iad-int.37signals.com # ssh app@fizzy-lb-102.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com # fizzy-lb-01.sc-chi-int.37signals.com # ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com \ --read-target=fizzy-app-01.sc-chi-int.37signals.com \ --read-target=fizzy-app-02.sc-chi-int.37signals.com # fizzy-lb-02.sc-chi-int.37signals.com # ssh app@fizzy-lb-02.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com \ --read-target=fizzy-app-01.sc-chi-int.37signals.com \ --read-target=fizzy-app-02.sc-chi-int.37signals.com # fizzy-lb-401.df-ams-int.37signals.com # ssh app@fizzy-lb-401.df-ams-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com \ --read-target=fizzy-app-401.df-ams-int.37signals.com \ --read-target=fizzy-app-402.df-ams-int.37signals.com # fizzy-lb-402.df-ams-int.37signals.com # ssh app@fizzy-lb-402.df-ams-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy.do \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-app-101.df-iad-int.37signals.com \ --target=fizzy-app-102.df-iad-int.37signals.com \ --read-target=fizzy-app-401.df-ams-int.37signals.com \ --read-target=fizzy-app-402.df-ams-int.37signals.com ================================================ FILE: saas/script/configure-lb-staging.sh ================================================ #!/usr/bin/env bash set -e # fizzy-staging-lb-01.sc-chi-int.37signals.com # ssh app@fizzy-staging-lb-01.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ --target=fizzy-staging-app-102.df-iad-int.37signals.com \ --read-target=fizzy-staging-app-01.sc-chi-int.37signals.com \ --read-target=fizzy-staging-app-02.sc-chi-int.37signals.com # fizzy-staging-lb-101.df-iad-int.37signals.com # ssh app@fizzy-staging-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ --target=fizzy-staging-app-102.df-iad-int.37signals.com # fizzy-staging-lb-401.df-ams-int.37signals.com # ssh app@fizzy-staging-lb-401.df-ams-int.37signals.com \ docker exec fizzy-load-balancer \ kamal-proxy deploy fizzy \ --force \ --tls \ --host=app.fizzy-staging.com \ --writer-affinity-timeout=0 \ --tls-acme-cache-path=/certificates \ --target=fizzy-staging-app-101.df-iad-int.37signals.com \ --target=fizzy-staging-app-102.df-iad-int.37signals.com \ --read-target=fizzy-staging-app-401.df-ams-int.37signals.com \ --read-target=fizzy-staging-app-402.df-ams-int.37signals.com ================================================ FILE: saas/test/controllers/.keep ================================================ ================================================ FILE: saas/test/controllers/admin/audits_controller_test.rb ================================================ require "test_helper" class Admin::AuditsControllerTest < ActionDispatch::IntegrationTest # Test authentication via the Audits1984::SessionsController#index endpoint, # which inherits from Admin::AuditsController through Audits1984::ApplicationController. test "unauthenticated access is forbidden" do untenanted do get saas.admin_audits1984_path assert_redirected_to new_session_path end end test "logged-in non-staff access is forbidden" do sign_in_as :jz untenanted do get saas.admin_audits1984_path end assert_response :forbidden end test "logged-in staff access is allowed" do sign_in_as :david untenanted do get saas.admin_audits1984_path end assert_response :success end test "invalid bearer token is forbidden" do untenanted do get saas.admin_audits1984_path, headers: { "Authorization" => "Bearer invalid_token" } end assert_response :unauthorized end test "valid bearer token is allowed" do token = Audits1984::AuditorToken.generate_for(identities(:david)) untenanted do get saas.admin_audits1984_path, headers: { "Authorization" => "Bearer #{token}" } end assert_response :success end test "expired bearer token is forbidden" do token = Audits1984::AuditorToken.generate_for(identities(:david)) Audits1984::AuditorToken.update_all(expires_at: 1.day.ago) untenanted do get saas.admin_audits1984_path, headers: { "Authorization" => "Bearer #{token}" } end assert_response :unauthorized end test "bearer token for non-staff user is forbidden" do # Even with a valid token, non-staff users should be denied access. # This handles the case where a user's staff privileges are revoked # after a token was issued. token = Audits1984::AuditorToken.generate_for(identities(:jz)) untenanted do get saas.admin_audits1984_path, headers: { "Authorization" => "Bearer #{token}" } end assert_response :forbidden end end ================================================ FILE: saas/test/controllers/admin/stats_controller_test.rb ================================================ require "test_helper" class Admin::StatsControllerTest < ActionDispatch::IntegrationTest test "staff can access stats" do sign_in_as :david untenanted do get saas.admin_stats_path end assert_response :success end test "non-staff cannot access stats" do sign_in_as :jz untenanted do get saas.admin_stats_path end assert_response :forbidden end end ================================================ FILE: saas/test/controllers/card/storage_limited/commenting_test.rb ================================================ require "test_helper" class Card::StorageLimited::CommentingTest < ActionDispatch::IntegrationTest test "cannot create comments when storage limit exceeded" do sign_in_as :david Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) Identity.any_instance.stubs(:staff?).returns(false) assert_no_difference -> { Comment.count } do post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug), params: { comment: { body: "Blocked comment" } }, as: :turbo_stream end assert_response :forbidden end test "can create comments when under storage limit" do sign_in_as :david assert_difference -> { Comment.count } do post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug), params: { comment: { body: "Allowed comment" } }, as: :turbo_stream end assert_response :success end test "staff can create comments even when storage limit exceeded" do sign_in_as :david Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) assert_difference -> { Comment.count } do post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug), params: { comment: { body: "Staff comment" } }, as: :turbo_stream end assert_response :success end end ================================================ FILE: saas/test/controllers/card/storage_limited/creation_test.rb ================================================ require "test_helper" class Card::StorageLimited::CreationTest < ActionDispatch::IntegrationTest test "cannot create cards via JSON when storage limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) assert_no_difference -> { Card.count } do post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { title: "Blocked card" } }, as: :json end assert_response :forbidden end test "can create cards via HTML when storage limit exceeded since they become drafts" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) accounts(:initech).update_column(:cards_count, 100) boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all assert_difference -> { Card.count } do post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug) end assert_response :redirect assert Card.last.drafted? end test "can create cards via JSON when under storage limit" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(500.megabytes) accounts(:initech).update_column(:cards_count, 100) assert_difference -> { Card.count } do post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { title: "Allowed card" } }, as: :json end assert_response :created end test "staff can create cards via JSON even when storage limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) Identity.any_instance.stubs(:staff?).returns(true) accounts(:initech).update_column(:cards_count, 100) assert_difference -> { Card.count } do post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { title: "Staff card" } }, as: :json end assert_response :created end end ================================================ FILE: saas/test/controllers/card/storage_limited/publishing_test.rb ================================================ require "test_helper" class Card::StorageLimited::PublishingTest < ActionDispatch::IntegrationTest test "cannot publish cards when storage limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :forbidden assert cards(:unfinished_thoughts).reload.drafted? end test "can publish cards when under storage limit" do sign_in_as :mike post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :redirect assert cards(:unfinished_thoughts).reload.published? end test "staff can publish cards even when storage limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) Identity.any_instance.stubs(:staff?).returns(true) post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :redirect assert cards(:unfinished_thoughts).reload.published? end end ================================================ FILE: saas/test/controllers/card/storage_limited_test.rb ================================================ require "test_helper" class Card::StorageLimitedTest < ActionDispatch::IntegrationTest test "draft card shows storage limit notice instead of create buttons when limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success assert_select ".card-perma__notch" do assert_select "strong", text: /used all/ assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy" end assert_select ".card-perma__notch-new-card-buttons", count: 0 end test "draft card shows create buttons when under storage limit" do sign_in_as :mike get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success assert_select ".card-perma__notch-new-card-buttons" end test "staff sees create buttons even when storage limit exceeded" do sign_in_as :mike Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) Identity.any_instance.stubs(:staff?).returns(true) get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success assert_select ".card-perma__notch-new-card-buttons" end end ================================================ FILE: saas/test/controllers/comment/storage_limited_test.rb ================================================ require "test_helper" class Comment::StorageLimitedTest < ActionDispatch::IntegrationTest test "published card shows storage limit notice instead of comment form when limit exceeded" do sign_in_as :david Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) Identity.any_instance.stubs(:staff?).returns(false) get card_path(cards(:logo), script_name: accounts(:"37s").slug) assert_response :success assert_select "strong", text: /used all/ assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy" assert_select "##{dom_id(cards(:logo), :new_comment)}", count: 0 end test "published card shows comment form when under storage limit" do sign_in_as :david get card_path(cards(:logo), script_name: accounts(:"37s").slug) assert_response :success assert_select "##{dom_id(cards(:logo), :new_comment)}" end test "staff sees comment form even when storage limit exceeded" do sign_in_as :david Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) get card_path(cards(:logo), script_name: accounts(:"37s").slug) assert_response :success assert_select "##{dom_id(cards(:logo), :new_comment)}" end end ================================================ FILE: saas/test/controllers/my/devices_controller_test.rb ================================================ require "test_helper" class My::DevicesControllerTest < ActionDispatch::IntegrationTest setup do @identity = identities(:david) sign_in_as :david end test "index shows identity's devices" do @identity.devices.create!(token: "test_token_123", platform: "apple", name: "iPhone 15 Pro") untenanted { get saas.my_devices_path } assert_response :success assert_select "strong", "iPhone 15 Pro" assert_select "li", /iOS/ end test "index shows empty state when no devices" do @identity.devices.delete_all untenanted { get saas.my_devices_path } assert_response :success assert_select "h1", /No devices registered/ end test "show notification settings with registered devices" do @identity.devices.create!(token: "test_token", platform: "apple", name: "iPhone 15 Pro") get notifications_settings_path assert_response :success end test "index requires authentication" do sign_out untenanted { get saas.my_devices_path } assert_response :redirect end test "creates a new device via api" do token = SecureRandom.hex(32) assert_difference -> { ApplicationPushDevice.count }, 1 do untenanted do post saas.my_devices_path, params: { token: token, platform: "apple", name: "iPhone 15 Pro" }, as: :json end end assert_response :created device = ApplicationPushDevice.last assert_equal token, device.token assert_equal "apple", device.platform assert_equal "iPhone 15 Pro", device.name assert_equal @identity, device.owner end test "creates android device" do untenanted do post saas.my_devices_path, params: { token: SecureRandom.hex(32), platform: "google", name: "Pixel 8" }, as: :json end assert_response :created device = ApplicationPushDevice.last assert_equal "google", device.platform end test "same token can be registered by multiple identities" do shared_token = "shared_push_token_123" other_identity = identities(:kevin) # Other identity registers the token first other_device = other_identity.devices.create!( token: shared_token, platform: "apple", name: "Kevin's iPhone" ) # Current identity registers the same token with their own device assert_difference -> { ApplicationPushDevice.count }, 1 do untenanted do post saas.my_devices_path, params: { token: shared_token, platform: "apple", name: "David's iPhone" }, as: :json end end assert_response :created # Both identities have their own device records assert_equal shared_token, other_device.reload.token assert_equal other_identity, other_device.owner davids_device = @identity.devices.last assert_equal shared_token, davids_device.token assert_equal @identity, davids_device.owner end test "rejects invalid platform" do untenanted do post saas.my_devices_path, params: { token: SecureRandom.hex(32), platform: "windows", name: "Surface" }, as: :json end assert_response :unprocessable_entity end test "rejects missing token" do untenanted do post saas.my_devices_path, params: { platform: "apple", name: "iPhone" }, as: :json end assert_response :bad_request end test "create requires authentication" do sign_out untenanted do post saas.my_devices_path, params: { token: SecureRandom.hex(32), platform: "apple" }, as: :json end assert_response :redirect end test "destroys device by id" do device = @identity.devices.create!( token: "token_to_delete", platform: "apple", name: "iPhone" ) assert_difference -> { ApplicationPushDevice.count }, -1 do untenanted { delete saas.my_device_path(device) } end assert_redirected_to saas.my_devices_path(script_name: nil) assert_not ApplicationPushDevice.exists?(device.id) end test "returns not found when device not found by id" do assert_no_difference "ApplicationPushDevice.count" do untenanted { delete saas.my_device_path(id: "nonexistent") } end assert_response :not_found end test "returns not found for another identity's device by id" do other_identity = identities(:kevin) device = other_identity.devices.create!( token: "other_identity_token", platform: "apple", name: "Other iPhone" ) assert_no_difference "ApplicationPushDevice.count" do untenanted { delete saas.my_device_path(device) } end assert_response :not_found assert ApplicationPushDevice.exists?(device.id) end test "destroy by id requires authentication" do device = @identity.devices.create!( token: "my_token", platform: "apple", name: "iPhone" ) sign_out untenanted { delete saas.my_device_path(device) } assert_response :redirect assert ApplicationPushDevice.exists?(device.id) end test "destroys device by token" do device = @identity.devices.create!( token: "token_to_unregister", platform: "apple", name: "iPhone" ) assert_difference -> { ApplicationPushDevice.count }, -1 do untenanted { delete saas.my_device_path("token_to_unregister"), as: :json } end assert_response :no_content assert_not ApplicationPushDevice.exists?(device.id) end test "returns not found when device not found by token" do assert_no_difference "ApplicationPushDevice.count" do untenanted { delete saas.my_device_path("nonexistent_token"), as: :json } end assert_response :not_found end test "returns not found for another identity's device by token" do other_identity = identities(:kevin) device = other_identity.devices.create!( token: "other_identity_token", platform: "apple", name: "Other iPhone" ) assert_no_difference "ApplicationPushDevice.count" do untenanted { delete saas.my_device_path("other_identity_token"), as: :json } end assert_response :not_found assert ApplicationPushDevice.exists?(device.id) end test "destroy by token requires authentication" do device = @identity.devices.create!( token: "my_token", platform: "apple", name: "iPhone" ) sign_out untenanted { delete saas.my_device_path("my_token"), as: :json } assert_response :redirect assert ApplicationPushDevice.exists?(device.id) end end ================================================ FILE: saas/test/controllers/non_production_remote_access_test.rb ================================================ require "test_helper" class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest test "employee can access in staging environment" do assert_predicate identities(:david), :employee? sign_in_as :david Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) get cards_path assert_response :success end test "non-employee cannot access in staging environment" do identities(:jz).update!(email_address: "david@example.com") assert_not_predicate identities(:jz), :employee? sign_in_as :jz Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) get cards_path assert_response :forbidden end test "non-employee can access in production environment" do identities(:jz).update!(email_address: "david@example.com") assert_not_predicate identities(:jz), :employee? sign_in_as :jz Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) get cards_path assert_response :success end test "non-employee can access in beta environment" do identities(:jz).update!(email_address: "david@example.com") assert_not_predicate identities(:jz), :employee? sign_in_as :jz Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("beta")) get cards_path assert_response :success end test "non-employee can access in local environment" do identities(:jz).update!(email_address: "david@example.com") assert_not_predicate identities(:jz), :employee? sign_in_as :jz get cards_path assert_response :success end end ================================================ FILE: saas/test/fixtures/application_push_devices.yml ================================================ davids_iphone: name: iPhone 15 Pro token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd platform: apple owner: david (User) davids_pixel: name: Pixel 8 token: def456abc123def456abc123def456abc123def456abc123def456abc123defg platform: google owner: david (User) kevins_iphone: name: iPhone 14 token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890 platform: apple owner: kevin (User) ================================================ FILE: saas/test/fixtures/files/.keep ================================================ ================================================ FILE: saas/test/helpers/.keep ================================================ ================================================ FILE: saas/test/integration/.keep ================================================ ================================================ FILE: saas/test/lib/true_client_ip_test.rb ================================================ require "test_helper" class TrackTrueClientIpTest < ActiveSupport::TestCase setup do @app = ->(env) { [ 200, {}, [ "OK" ] ] } @middleware = TrackTrueClientIp.new(@app) end test "sets X-Forwarded-For header when True-Client-IP header is present" do env = { "HTTP_TRUE_CLIENT_IP" => "123.123.123.123" } @middleware.call(env) assert_equal "123.123.123.123", env["HTTP_X_FORWARDED_FOR"] end test "does not modify environment when True-Client-IP header is absent" do env = {} @middleware.call(env) assert_nil env["HTTP_X_FORWARDED_FOR"] env = { "HTTP_X_FORWARDED_FOR" => "234.234.234.234" } @middleware.call(env) assert_equal "234.234.234.234", env["HTTP_X_FORWARDED_FOR"] end test "calls the next middleware in the stack" do called = false app = ->(env) { called = true; [ 200, {}, [ "OK" ] ] } middleware = TrackTrueClientIp.new(app) middleware.call({}) assert called end end ================================================ FILE: saas/test/mailers/.keep ================================================ ================================================ FILE: saas/test/models/account/storage_exception_test.rb ================================================ require "test_helper" class Account::StorageExceptionTest < ActiveSupport::TestCase test "storage limit returns default when no exception exists" do assert_equal Account::StorageLimited::DEFAULT_STORAGE_LIMIT, accounts(:initech).storage_limit end test "storage limit returns exception value when one exists" do account = accounts(:initech) account.add_storage_exception(5.gigabytes) assert_equal 5.gigabytes, account.storage_limit end test "add storage exception creates a new record" do account = accounts(:initech) assert_difference -> { Account::StorageException.count } do account.add_storage_exception(2.gigabytes) end assert_equal 2.gigabytes, account.storage_exception.bytes_allowed end test "add storage exception updates existing record" do account = accounts(:initech) account.add_storage_exception(2.gigabytes) assert_no_difference -> { Account::StorageException.count } do account.add_storage_exception(10.gigabytes) end assert_equal 10.gigabytes, account.storage_exception.reload.bytes_allowed end test "exceeding storage limit respects exception" do account = accounts(:initech) Account.any_instance.stubs(:bytes_used).returns(2.gigabytes) assert account.exceeding_storage_limit? account.add_storage_exception(5.gigabytes) assert_not account.exceeding_storage_limit? end end ================================================ FILE: saas/test/models/account/storage_limited_test.rb ================================================ require "test_helper" class Account::StorageLimitedTest < ActiveSupport::TestCase test "exceeding storage limit when bytes used exceeds 1 GB" do Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) assert accounts(:initech).exceeding_storage_limit? end test "not exceeding storage limit when bytes used equals 1 GB" do Account.any_instance.stubs(:bytes_used).returns(1.gigabyte) assert_not accounts(:initech).exceeding_storage_limit? end test "not exceeding storage limit when under 1 GB" do Account.any_instance.stubs(:bytes_used).returns(500.megabytes) assert_not accounts(:initech).exceeding_storage_limit? end test "nearing storage limit when within 500 MB of the limit" do Account.any_instance.stubs(:bytes_used).returns(600.megabytes) assert accounts(:initech).nearing_storage_limit? end test "not nearing storage limit when well under the threshold" do Account.any_instance.stubs(:bytes_used).returns(400.megabytes) assert_not accounts(:initech).nearing_storage_limit? end test "not nearing storage limit when already exceeding it" do Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1) assert_not accounts(:initech).nearing_storage_limit? end end ================================================ FILE: saas/test/models/identity_test.rb ================================================ require "test_helper" class Fizzy::Saas::IdentityTest < ActiveSupport::TestCase test "#employee? returns true for 37signals.com domains" do identity = Identity.new(email_address: "mike@37signals.com") assert_predicate identity, :employee? end test "#employee? returns true for basecamp.com domains" do identity = Identity.new(email_address: "mike@basecamp.com") assert_predicate identity, :employee? end test "#employee? returns false for other domains" do identity = Identity.new(email_address: "mike@example.com") assert_not_predicate identity, :employee? end end ================================================ FILE: saas/test/models/notification/push_target/native_test.rb ================================================ require "test_helper" class Notification::PushTarget::NativeTest < ActiveSupport::TestCase setup do @user = users(:kevin) @identity = @user.identity @notification = notifications(:logo_assignment_kevin) # Ensure user has no web push subscriptions (we want to test native push independently) @user.push_subscriptions.delete_all end test "payload category returns assignment for card_assigned" do notification = notifications(:logo_assignment_kevin) assert_equal "assignment", notification.payload.category end test "payload category returns comment for comment_created" do notification = notifications(:layout_commented_kevin) assert_equal "comment", notification.payload.category end test "payload category returns mention for mentions" do notification = notifications(:logo_mentioned_david) assert_equal "mention", notification.payload.category end test "payload category returns card for other card events" do @notification.update!(source: events(:logo_published)) assert_equal "card", @notification.payload.category end test "pushes to native devices when user has devices" do stub_push_services @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") assert_native_push_delivery(count: 1) do Notification::PushTarget::Native.new(@notification).process end end test "does not push when user has no devices" do @identity.devices.delete_all assert_no_native_push_delivery do Notification::PushTarget::Native.new(@notification).process end end test "pushes to multiple devices" do stub_push_services @identity.devices.delete_all @identity.devices.create!(token: "token1", platform: "apple", name: "iPhone") @identity.devices.create!(token: "token2", platform: "google", name: "Pixel") assert_native_push_delivery(count: 2) do Notification::PushTarget::Native.new(@notification).process end end test "native notification includes required fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not_nil native.title assert_not_nil native.body assert_equal "default", native.sound end test "native notification sets thread_id from card" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal @notification.card.id, native.thread_id end test "native notification sets high_priority for assignments" do notification = notifications(:logo_assignment_kevin) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert native.high_priority end test "native notification sets high_priority for mentions" do notification = notifications(:logo_mentioned_david) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert native.high_priority end test "native notification sets normal priority for comments" do notification = notifications(:layout_commented_kevin) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert_not native.high_priority end test "native notification sets normal priority for other card events" do @notification.update!(source: events(:logo_published)) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not native.high_priority end test "native notification includes apple-specific fields" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal 1, native.apple_data.dig(:aps, :"mutable-content") assert_not_nil native.apple_data.dig(:aps, :category) end test "native notification sets time-sensitive interruption level for assignments" do notification = notifications(:logo_assignment_kevin) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") end test "native notification sets time-sensitive interruption level for mentions" do notification = notifications(:logo_mentioned_david) notification.user.identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert_equal "time-sensitive", native.apple_data.dig(:aps, :"interruption-level") end test "native notification sets active interruption level for comments" do notification = notifications(:layout_commented_kevin) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(notification) native = push.send(:native_notification) assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") end test "native notification sets active interruption level for other card events" do @notification.update!(source: events(:logo_published)) @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal "active", native.apple_data.dig(:aps, :"interruption-level") end test "native notification sets android notification to nil for data-only" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_nil native.google_data.dig(:android, :notification) end test "native notification includes data payload" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_not_nil native.data[:url] assert_equal @notification.account.id, native.data[:account_id] assert_equal @notification.account.slug, native.data[:account_slug] assert_equal @notification.creator.name, native.data[:creator_name] end test "native notification includes base_url without account slug" do @identity.devices.create!(token: "test123", platform: "apple", name: "Test iPhone") push = Notification::PushTarget::Native.new(@notification) native = push.send(:native_notification) assert_equal "http://example.com", native.data[:base_url] end private def assert_native_push_delivery(count: 1, &block) assert_enqueued_jobs count, only: ApplicationPushNotificationJob do perform_enqueued_jobs only: Notification::PushJob, &block end end def assert_no_native_push_delivery(&block) assert_enqueued_jobs 0, only: ApplicationPushNotificationJob do perform_enqueued_jobs only: Notification::PushJob, &block end end def stub_push_services ActionPushNative.stubs(:service_for).returns(stub(push: true)) end end ================================================ FILE: saas/test/models/session/devices_test.rb ================================================ require "test_helper" class Session::DevicesTest < ActiveSupport::TestCase setup do @session = sessions(:david) @identity = @session.identity end test "destroying session destroys associated devices" do device = ApplicationPushDevice.register( session: @session, token: "test_token", platform: "apple", name: "Test iPhone" ) assert_difference -> { ApplicationPushDevice.count }, -1 do @session.destroy end assert_nil ApplicationPushDevice.find_by(id: device.id) end test "destroying session does not destroy devices from other sessions" do other_session = sessions(:kevin) device = ApplicationPushDevice.register( session: other_session, token: "other_token", platform: "apple", name: "Other iPhone" ) assert_no_difference -> { ApplicationPushDevice.count } do @session.destroy end assert ApplicationPushDevice.exists?(device.id) end end ================================================ FILE: saas/test/models/signup_test.rb ================================================ require "test_helper" class Fizzy::Saas::SignupTest < ActiveSupport::TestCase test "#complete creates queenbee account and uses its id as tenant" do queenbee_account = mock("queenbee_account") queenbee_account.stubs(:id).returns(123456) Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) Account.any_instance.expects(:setup_customer_template).once Current.without_account do assert_changes -> { Account.count }, +1 do sequence_value_before = Account::ExternalIdSequence.value signup = Signup.new( full_name: "Kevin", identity: identities(:kevin) ) assert signup.complete assert signup.account assert_equal 123456, signup.account.external_account_id assert_equal sequence_value_before, Account::ExternalIdSequence.value end end end test "#complete calls cancel on queenbee account when account creation fails" do queenbee_account = mock("queenbee_account") queenbee_account.stubs(:id).returns(789012) queenbee_account.expects(:cancel).once Queenbee::Remote::Account.expects(:create!).once.returns(queenbee_account) Account.any_instance.stubs(:setup_customer_template).raises(StandardError.new("Account setup failed")) Current.without_account do signup = Signup.new( full_name: "Kevin", identity: identities(:kevin) ) assert_not signup.complete assert_includes signup.errors[:base], "Something went wrong, and we couldn't create your account. Please give it another try." end end end ================================================ FILE: script/create-identities.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" # Loop through all tenants and create identities for users ApplicationRecord.with_each_tenant do |tenant| puts "Processing tenant: #{tenant}" User.find_each do |user| next if user.system? # Use IdentityProvider to link the user's email to this tenant # This will find_or_create the identity and link it to the tenant IdentityProvider.link(email_address: user.email_address, to: tenant) puts " ✅ Linked identity for user #{user.id} (#{user.email_address}) to tenant '#{tenant}'" end puts " Completed tenant: #{tenant}" puts end puts "All identities created successfully!" ================================================ FILE: script/fetch-prod-db.rb ================================================ #!/usr/bin/env ruby require "tmpdir" require "fileutils" require "open3" if ARGV.size != 1 warn "Usage: #{$PROGRAM_NAME} TENANT_ID" exit 1 end tenant_id = ARGV[0] # Automatically detect the fizzy-web-production container puts "→ Detecting fizzy-web-production container..." container_output, status = Open3.capture2(%(ssh app@fizzy-app-101 "docker ps --format '{{.Names}}' | grep fizzy-web-production")) abort("Failed to detect container") unless status.success? CONTAINER = container_output.strip abort("No fizzy-web-production container found") if CONTAINER.empty? puts "→ Using container: #{CONTAINER}" REMOTE_PATH = "/rails/storage/tenants/production/#{tenant_id}/db/main.sqlite3.1" Dir.mktmpdir do |tmpdir| local_file = File.join(tmpdir, "main.sqlite3") puts "→ Copying #{REMOTE_PATH} from container to #{local_file}" cmd = %(ssh app@fizzy-app-101 "docker cp #{CONTAINER}:#{REMOTE_PATH} -" | tar -xOf - > #{local_file}) system(cmd) or abort("Failed to copy database file") puts "→ Running script/load-prod-db-in-dev.rb with #{local_file}" exec("bundle", "exec", "ruby", "script/load-prod-db-in-dev.rb", local_file) end ================================================ FILE: script/fix-active-storage-links.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" require "pathname" require "uri" require "base64" require "json" class FixActiveStorage attr_reader :skipped, :processed, :scope def initialize(scope = nil) @scope = scope || ActionText::RichText.all.where("body LIKE '%/rails/active_storage/%'") @mapping = {} @skipped = 0 @processed = 0 @users = {} @memberships = {} @attachments = {} @identities = {} end def ingest_blob_keys(db_path) models = Models.new(db_path) @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 @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 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 ================================================ FILE: script/import-sqlite-database.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" require "pathname" require "optparse" class AccountExistsError < StandardError; end class Import FIX_LINK_HOSTS = { "fizzy.37signals.com" => "app.fizzy.do", "box-car.com" => "app.fizzy.do", "app.box-car.com" => "app.fizzy.do" }.freeze attr_reader :db_path, :untenanted_db_path, :skip_already_imported attr_reader :account, :tenant, :mapping def initialize(db_path, untenanted_db_path, skip_already_imported: false) @db_path = Pathname(db_path) @untenanted_db_path = Pathname(untenanted_db_path) @skip_already_imported = skip_already_imported @mapping = nil end def import_database raise "The given database file doesn't exist" unless db_path.exist? @mapping = {} duration = ActiveSupport::Benchmark.realtime do ApplicationRecord.transaction do setup_account ActiveRecord::Base.no_touching do Current.with(account: account) do begin Webhook.skip_callback(:create, :after, :create_delinquency_tracker!) Comment.skip_callback(:commit, :after, :watch_card_by_creator) Comment.skip_callback(:commit, :after, :track_creation) Mention.skip_callback(:commit, :after, :watch_source_by_mentionee) Notification.skip_callback(:commit, :after, :broadcast_unread) Notification.skip_callback(:create, :after, :bundle) Reaction.skip_callback(:create, :after, :register_card_activity) Card.skip_callback(:save, :before, :set_default_title) Card.skip_callback(:update, :after, :handle_board_change) ActiveStorage::Blob.skip_callback(:update, :after, :touch_attachments) ActiveStorage::Blob.skip_callback(:commit, :after, :update_service_metadata) ActiveStorage::Attachment.skip_callback(:commit, :after, :mirror_blob_later) ActiveStorage::Attachment.skip_callback(:commit, :after, :analyze_blob_later) ActiveStorage::Attachment.skip_callback(:commit, :after, :transform_variants_later) ActiveStorage::Attachment.skip_callback(:commit, :after, :purge_dependent_blob_later) rescue => e puts "⚠️ Warning: Could not skip some callbacks: #{e.message}" end Event.suppress do copy_users copy_boards copy_accesses copy_columns copy_cards copy_steps copy_comments copy_mentions copy_reactions copy_tags copy_watches copy_pins end copy_events Event.suppress do copy_webhooks copy_push_subscriptions copy_filters copy_entropies end copy_notifications copy_notification_bundles fix_links unless Rails.env.production? # Don't spam real webhooks Webhook.all.update_all(active: false) # Don't send emails to real users User::Settings.all.update_all(bundle_email_frequency: :never) end end end end end puts "🎉 Import complete! (#{duration.round(2)}s)" rescue AccountExistsError => e raise e unless skip_already_imported end private def step(start_message, completion_message) puts "⏩ #{start_message}" result = nil duration = ActiveSupport::Benchmark.realtime do result = yield end interpolations = { duration: "#{duration.round(2)}s" } interpolations.merge!(result) if result.is_a?(Hash) completion_text = completion_message % interpolations puts "✅ #{completion_text}" result end def generate_uuid ActiveRecord::Type::Uuid.generate end def setup_account step("Setting up account", "Account set up in %{duration}") do oldest_admin = import.users.order(id: :asc).admin.first raise "No admin user found in the database" unless oldest_admin membership = untenanted.memberships.find(oldest_admin.membership_id) account = import.accounts.sole new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) if Account.all.exists?(external_account_id: account.external_account_id) raise AccountExistsError, "Account already exists" else @account = Account.create_with_owner( account: { external_account_id: account.external_account_id, name: account.name.truncate(255, omission: "") }, owner: { name: oldest_admin.name.truncate(255, omission: ""), identity: new_identity } ) @tenant = @account.external_account_id @admin = @account.users.find_by(role: :owner) end old_join_code = import.account_join_codes.sole attributes = { usage_count: old_join_code.usage_count, usage_limit: old_join_code.usage_limit } attributes[:code] = old_join_code.code unless Account::JoinCode.all.exists?(code: old_join_code.code) @account.join_code.update_columns(**attributes) end end def copy_users step("Copying users", "Copied %{count} users in %{duration}") do mapping[:users] ||= {} import.users.find_each do |old_user| new_identity = nil if old_user.membership_id && old_user.active? membership = untenanted.memberships.find(old_user.membership_id) new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address) end new_user = if new_identity == @admin.identity @admin else User.create!( account: account, identity: new_identity, name: old_user.name.truncate(255, omission: ""), role: old_user.role, active: old_user.active, ) end old_settings = old_user.settings if old_settings User::Settings.create!( user: new_user, bundle_email_frequency: old_settings.bundle_email_frequency, timezone_name: old_settings.timezone_name ) end mapping[:users][old_user.id] = new_user.id end { count: mapping[:users].size } end end def copy_boards step("Copying boards", "Copied %{count} boards in %{duration}") do mapping[:boards] ||= {} import.boards.find_each do |old_board| new_board = Board.create!( account_id: account.id, creator_id: mapping[:users][old_board.creator_id], name: old_board.name.truncate(255, omission: ""), all_access: old_board.all_access, created_at: old_board.created_at, updated_at: old_board.updated_at ) old_publication = old_board.publication if old_publication Board::Publication.create!( board_id: new_board.id, key: old_publication.key, created_at: old_publication.created_at, updated_at: old_publication.updated_at ) end mapping[:boards][old_board.id] = new_board.id end { count: mapping[:boards].size } end end def copy_columns step("Copying columns", "Copied %{count} columns in %{duration}") do mapping[:columns] ||= {} import.columns.find_each do |old_column| new_column = Column.create!( account_id: account.id, board_id: mapping[:boards][old_column.board_id], name: old_column.name.truncate(255, omission: ""), color: old_column.color, position: old_column.position, created_at: old_column.created_at, updated_at: old_column.updated_at ) mapping[:columns][old_column.id] = new_column.id end { count: mapping[:columns].size } end end def copy_cards step("Copying cards", "Copied %{count} cards in %{duration}") do mapping[:cards] ||= {} account.update_columns(cards_count: import.cards.maximum(:id) || 0) activity_spikes_to_insert = [] engagements_to_insert = [] goldnesses_to_insert = [] not_nows_to_insert = [] assignments_to_insert = [] closures_to_insert = [] import.cards.in_batches(of: 1000) do |batch| cards_to_insert = [] batch.each do |old_card| new_id = generate_uuid mapping[:cards][old_card.id] = new_id # Map old 'creating' status to 'drafted' since it's no longer a valid enum value status = old_card.status == "creating" ? "drafted" : old_card.status cards_to_insert << { id: new_id, number: old_card.id, account_id: account.id, board_id: mapping[:boards][old_card.board_id], column_id: old_card.column_id ? mapping[:columns][old_card.column_id] : nil, creator_id: mapping[:users][old_card.creator_id], title: old_card.title, status: status, due_on: old_card.due_on, last_active_at: old_card.last_active_at, created_at: old_card.created_at, updated_at: old_card.updated_at } old_activity_spike = old_card.activity_spike if old_activity_spike activity_spikes_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, created_at: old_activity_spike.created_at, updated_at: old_activity_spike.updated_at } end old_engagement = old_card.engagement if old_engagement engagements_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, status: old_engagement.status, created_at: old_engagement.created_at, updated_at: old_engagement.updated_at } end old_goldness = old_card.goldness if old_goldness goldnesses_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, created_at: old_goldness.created_at, updated_at: old_goldness.updated_at } end old_not_now = old_card.not_now if old_not_now not_nows_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, user_id: old_not_now.user_id ? mapping[:users][old_not_now.user_id] : nil, created_at: old_not_now.created_at, updated_at: old_not_now.updated_at } end old_card.assignments.each do |old_assignment| assignments_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, assignee_id: mapping[:users][old_assignment.assignee_id], assigner_id: mapping[:users][old_assignment.assigner_id], created_at: old_assignment.created_at, updated_at: old_assignment.updated_at } end old_closure = old_card.closure if old_closure closures_to_insert << { id: generate_uuid, account_id: account.id, card_id: new_id, user_id: old_closure.user_id ? mapping[:users][old_closure.user_id] : nil, created_at: old_closure.created_at, updated_at: old_closure.updated_at } end end Card.insert_all(cards_to_insert) end Card::ActivitySpike.insert_all(activity_spikes_to_insert) if activity_spikes_to_insert.any? Card::Engagement.insert_all(engagements_to_insert) if engagements_to_insert.any? Card::Goldness.insert_all(goldnesses_to_insert) if goldnesses_to_insert.any? Card::NotNow.insert_all(not_nows_to_insert) if not_nows_to_insert.any? Assignment.insert_all(assignments_to_insert) if assignments_to_insert.any? Closure.insert_all(closures_to_insert) if closures_to_insert.any? import.cards.find_each do |old_card| new_card_id = mapping[:cards][old_card.id] new_card = Card.find(new_card_id) copy_rich_text(old_card, new_card, "Card", "description") copy_attachment(old_card, new_card, "Card", "image") end { count: mapping[:cards].size } end end def copy_steps step("Copying steps", "Copied steps in %{duration}") do import.steps.in_batches(of: 1000) do |batch| steps_to_insert = [] batch.each do |old_step| steps_to_insert << { id: generate_uuid, account_id: account.id, card_id: mapping[:cards][old_step.card_id], content: old_step.content, completed: old_step.completed, created_at: old_step.created_at, updated_at: old_step.updated_at } end Step.insert_all(steps_to_insert) end end end def copy_comments step("Copying comments", "Copied %{count} comments in %{duration}") do mapping[:comments] ||= {} import.comments.in_batches(of: 1000) do |batch| comments_to_insert = [] batch.each do |old_comment| new_id = generate_uuid mapping[:comments][old_comment.id] = new_id comments_to_insert << { id: new_id, account_id: account.id, card_id: mapping[:cards][old_comment.card_id], creator_id: mapping[:users][old_comment.creator_id], created_at: old_comment.created_at, updated_at: old_comment.updated_at } end Comment.insert_all(comments_to_insert) end import.comments.find_each do |old_comment| new_comment_id = mapping[:comments][old_comment.id] new_comment = Comment.find(new_comment_id) copy_rich_text(old_comment, new_comment, "Comment", "body") end { count: mapping[:comments].size } end end def copy_mentions step("Copying mentions", "Copied %{count} mentions in %{duration}") do mapping[:mentions] ||= {} import.mentions.find_each do |old_mention| new_mention = Mention.create!( source_type: old_mention.source_type, source_id: mapping[old_mention.source_type.tableize.to_sym][old_mention.source_id], mentioner_id: mapping[:users][old_mention.mentioner_id], mentionee_id: mapping[:users][old_mention.mentionee_id], created_at: old_mention.created_at, updated_at: old_mention.updated_at ) mapping[:mentions][old_mention.id] = new_mention.id end { count: mapping[:mentions].size } end end def copy_accesses step("Copying accesses", "Copied %{count} accesses in %{duration}") do mapping[:accesses] ||= {} import.accesses.in_batches(of: 1000) do |batch| accesses_to_insert = [] batch.each do |old_access| new_id = generate_uuid mapping[:accesses][old_access.id] = new_id accesses_to_insert << { id: new_id, account_id: account.id, board_id: mapping[:boards][old_access.board_id], user_id: mapping[:users][old_access.user_id], involvement: old_access.involvement, accessed_at: old_access.accessed_at, created_at: old_access.created_at, updated_at: old_access.updated_at } end Access.insert_all(accesses_to_insert) end { count: mapping[:accesses].size } end end def copy_notifications step("Copying notifications", "Copied %{count} notifications in %{duration}") do mapping[:notifications] ||= {} import.notifications.in_batches(of: 1000) do |batch| notifications_to_insert = [] batch.each do |old_notification| new_id = generate_uuid mapping[:notifications][old_notification.id] = new_id notifications_to_insert << { id: new_id, account_id: account.id, user_id: mapping[:users][old_notification.user_id], creator_id: old_notification.creator_id ? mapping[:users][old_notification.creator_id] : nil, source_type: old_notification.source_type, source_id: mapping.fetch(old_notification.source_type.tableize.to_sym)[old_notification.source_id], read_at: old_notification.read_at, created_at: old_notification.created_at, updated_at: old_notification.updated_at } end Notification.insert_all(notifications_to_insert) end { count: mapping[:notifications].size } end end def copy_notification_bundles step("Copying notification bundles", "Copied %{count} notification bundles in %{duration}") do mapping[:notification_bundles] ||= {} import.notification_bundles.in_batches(of: 1000) do |batch| bundles_to_insert = [] batch.each do |old_bundle| new_id = generate_uuid mapping[:notification_bundles][old_bundle.id] = new_id bundles_to_insert << { id: new_id, account_id: account.id, user_id: mapping[:users][old_bundle.user_id], status: old_bundle.status, starts_at: old_bundle.starts_at, ends_at: old_bundle.ends_at, created_at: old_bundle.created_at, updated_at: old_bundle.updated_at } end Notification::Bundle.insert_all(bundles_to_insert) end { count: mapping[:notification_bundles].size } end end def copy_entropies step("Copying entropies", "Copied entropies in %{duration}") do import.entropies.find_each do |old_entropy| container_id = case old_entropy.container_type when "Account" then account.id when "Board" then mapping[:boards][old_entropy.container_id] when "Card" then mapping[:cards][old_entropy.container_id] else next end Entropy.find_or_create_by!(account_id: account.id, container_type: old_entropy.container_type, container_id: container_id) do |entropy| entropy.auto_postpone_period = old_entropy.auto_postpone_period || 0 entropy.created_at = old_entropy.created_at entropy.updated_at = old_entropy.updated_at end end end end def copy_filters step("Copying filters", "Copied %{count} filters in %{duration}") do mapping[:filters] ||= {} # First, insert all filters import.filters.in_batches(of: 1000) do |batch| filters_to_insert = [] batch.each do |old_filter| new_id = generate_uuid mapping[:filters][old_filter.id] = new_id filters_to_insert << { id: new_id, account_id: account.id, creator_id: mapping[:users][old_filter.creator_id], params_digest: old_filter.params_digest, fields: old_filter.fields, created_at: old_filter.created_at, updated_at: old_filter.updated_at } end Filter.insert_all(filters_to_insert) end # Then, copy HABTM associations for each filter import.filters.find_each do |old_filter| new_filter = Filter.find(mapping[:filters][old_filter.id]) # Copy HABTM associations by finding valid mapped IDs first assignee_ids = import.assignees_filters.where(filter_id: old_filter.id) .filter_map { |join| mapping[:users][join.assignee_id] } new_filter.assignee_ids = assignee_ids if assignee_ids.any? creator_ids = import.creators_filters.where(filter_id: old_filter.id) .filter_map { |join| mapping[:users][join.creator_id] } new_filter.creator_ids = creator_ids if creator_ids.any? closer_ids = import.closers_filters.where(filter_id: old_filter.id) .filter_map { |join| mapping[:users][join.closer_id] } new_filter.closer_ids = closer_ids if closer_ids.any? board_ids = import.boards_filters.where(filter_id: old_filter.id) .filter_map { |join| mapping[:boards][join.board_id] } new_filter.board_ids = board_ids if board_ids.any? tag_ids = import.filters_tags.where(filter_id: old_filter.id) .filter_map { |join| mapping[:tags][join.tag_id] } new_filter.tag_ids = tag_ids if tag_ids.any? end { count: mapping[:filters].size } end end def copy_events step("Copying events", "Copied %{count} events in %{duration}") do mapping[:events] ||= {} import.events.in_batches(of: 1000) do |batch| events_to_insert = [] batch.each do |old_event| new_id = generate_uuid mapping[:events][old_event.id] = new_id events_to_insert << { id: new_id, account_id: account.id, board_id: mapping[:boards][old_event.board_id], creator_id: mapping[:users][old_event.creator_id], eventable_type: old_event.eventable_type, eventable_id: mapping[old_event.eventable_type.tableize.to_sym][old_event.eventable_id], action: old_event.action, particulars: old_event.particulars, created_at: old_event.created_at, updated_at: old_event.updated_at } end Event.insert_all(events_to_insert) end { count: mapping[:events].size } end end def copy_rich_text(old_record, new_record, record_type, name) old_rich_text = import.rich_texts.find_by(record_type: record_type, record_id: old_record.id, name: name) return unless old_rich_text new_rich_text = ActionText::RichText.create!( record: new_record, name: name, body: old_rich_text.body, created_at: old_rich_text.created_at, updated_at: old_rich_text.updated_at ) mapping[:rich_text] ||= {} mapping[:rich_text][old_rich_text.id] = new_rich_text.id import.attachments.where(record_type: "ActionText::RichText", record_id: old_rich_text.id).each do |old_attachment| copy_attachment(old_rich_text, new_rich_text, "ActionText::RichText", old_attachment.name) end end def copy_attachment(old_record, new_record, record_type, name) old_attachment = import.attachments.find_by(record_type: record_type, record_id: old_record.id, name: name) return unless old_attachment old_blob = import.blobs.find(old_attachment.blob_id) new_blob = ActiveStorage::Blob.find_or_create_by!(key: old_blob.key) do |blob| blob.filename = old_blob.filename blob.content_type = old_blob.content_type blob.metadata = old_blob.metadata blob.service_name = old_blob.service_name blob.byte_size = old_blob.byte_size blob.checksum = old_blob.checksum blob.created_at = old_blob.created_at end mapping[:blobs] ||= {} mapping[:blobs][old_blob.id] = new_blob.id # Copy variant records to prevent ActiveStorage from regenerating them copy_variant_records(old_blob, new_blob) new_attachment = ActiveStorage::Attachment.find_or_create_by!( name: name, record: new_record, blob: new_blob, created_at: old_attachment.created_at ) mapping[:attachments] ||= {} mapping[:attachments][old_attachment.id] = new_attachment.id end def copy_variant_records(old_blob, new_blob) import.variant_records.where(blob_id: old_blob.id).each do |old_variant_record| old_variant_blob = import.blobs.find_by(id: old_variant_record.id) next unless old_variant_blob new_variant_blob = ActiveStorage::Blob.find_or_create_by!(key: old_variant_blob.key) do |blob| blob.filename = old_variant_blob.filename blob.content_type = old_variant_blob.content_type blob.metadata = old_variant_blob.metadata blob.service_name = old_variant_blob.service_name blob.byte_size = old_variant_blob.byte_size blob.checksum = old_variant_blob.checksum blob.created_at = old_variant_blob.created_at end mapping[:blobs] ||= {} mapping[:blobs][old_variant_blob.id] = new_variant_blob.id ActiveStorage::VariantRecord.find_or_create_by!( id: new_variant_blob.id, account_id: account.id, blob_id: new_blob.id, variation_digest: old_variant_record.variation_digest ) end end def copy_reactions step("Copying reactions", "Copied %{count} reactions in %{duration}") do mapping[:reactions] ||= {} import.reactions.find_each do |old_reaction| # Truncate content to 16 characters to match current column limit content = old_reaction.content.truncate(16, omission: "") new_reaction = Reaction.create!( comment_id: mapping[:comments][old_reaction.comment_id], reacter_id: mapping[:users][old_reaction.reacter_id], content: content, created_at: old_reaction.created_at, updated_at: old_reaction.updated_at ) mapping[:reactions][old_reaction.id] = new_reaction.id end { count: mapping[:reactions].size } end end def copy_tags step("Copying tags", "Copied %{tags} tags and %{taggings} taggings in %{duration}") do mapping[:tags] ||= {} mapping[:taggings] ||= {} import.tags.find_each do |old_tag| new_tag = account.tags.find_or_create_by!(title: old_tag.title) do |t| t.created_at = old_tag.created_at t.updated_at = old_tag.updated_at end mapping[:tags][old_tag.id] = new_tag.id end import.taggings.find_each do |old_tagging| new_tagging = Tagging.create!( tag_id: mapping[:tags][old_tagging.tag_id], card_id: mapping[:cards][old_tagging.card_id], created_at: old_tagging.created_at, updated_at: old_tagging.updated_at ) mapping[:taggings][old_tagging.id] = new_tagging.id end { tags: mapping[:tags].size, taggings: mapping[:taggings].size } end end def copy_watches step("Copying watches", "Copied %{count} watches in %{duration}") do mapping[:watches] ||= {} import.watches.in_batches(of: 1000) do |batch| watches_to_insert = [] batch.each do |old_watch| new_id = generate_uuid mapping[:watches][old_watch.id] = new_id watches_to_insert << { id: new_id, account_id: account.id, user_id: mapping[:users][old_watch.user_id], card_id: mapping[:cards][old_watch.card_id], watching: old_watch.watching, created_at: old_watch.created_at, updated_at: old_watch.updated_at } end Watch.insert_all(watches_to_insert) end { count: mapping[:watches].size } end end def copy_pins step("Copying pins", "Copied %{count} pins in %{duration}") do mapping[:pins] ||= {} import.pins.in_batches(of: 1000) do |batch| pins_to_insert = [] batch.each do |old_pin| new_id = generate_uuid mapping[:pins][old_pin.id] = new_id pins_to_insert << { id: new_id, account_id: account.id, user_id: mapping[:users][old_pin.user_id], card_id: mapping[:cards][old_pin.card_id], created_at: old_pin.created_at, updated_at: old_pin.updated_at } end Pin.insert_all(pins_to_insert) end { count: mapping[:pins].size } end end def copy_webhooks step("Copying webhooks", "Copied %{webhooks} webhooks and %{deliveries} deliveries in %{duration}") do mapping[:webhooks] ||= {} mapping[:webhook_deliveries] ||= {} import.webhooks.find_each do |old_webhook| subscribed_actions = old_webhook.subscribed_actions subscribed_actions = JSON.parse(subscribed_actions) if subscribed_actions.is_a?(String) new_webhook = Webhook.create!( account_id: account.id, board_id: mapping[:boards][old_webhook.board_id], name: old_webhook.name.truncate(255, omission: ""), url: old_webhook.url, signing_secret: old_webhook.signing_secret, subscribed_actions: subscribed_actions, active: old_webhook.active, created_at: old_webhook.created_at, updated_at: old_webhook.updated_at ) mapping[:webhooks][old_webhook.id] = new_webhook.id old_tracker = import.webhook_delinquency_trackers.find_by(webhook_id: old_webhook.id) if old_tracker Webhook::DelinquencyTracker.find_or_create_by!(webhook_id: new_webhook.id) do |tracker| tracker.consecutive_failures_count = old_tracker.consecutive_failures_count tracker.first_failure_at = old_tracker.first_failure_at tracker.created_at = old_tracker.created_at tracker.updated_at = old_tracker.updated_at end end end import.webhook_deliveries.find_each do |old_delivery| new_delivery = Webhook::Delivery.create!( webhook_id: mapping[:webhooks][old_delivery.webhook_id], event_id: mapping[:events][old_delivery.event_id], state: old_delivery.state, request: old_delivery.request, response: old_delivery.response, created_at: old_delivery.created_at, updated_at: old_delivery.updated_at ) mapping[:webhook_deliveries][old_delivery.id] = new_delivery.id end { webhooks: mapping[:webhooks].size, deliveries: mapping[:webhook_deliveries].size } end end def copy_push_subscriptions step("Copying push subscriptions", "Copied %{count} push subscriptions in %{duration}") do mapping[:push_subscriptions] ||= {} import.push_subscriptions.find_each do |old_subscription| new_subscription = Push::Subscription.create!( account_id: account.id, user_id: mapping[:users][old_subscription.user_id], endpoint: old_subscription.endpoint, p256dh_key: old_subscription.p256dh_key, auth_key: old_subscription.auth_key, user_agent: old_subscription.user_agent, created_at: old_subscription.created_at, updated_at: old_subscription.updated_at ) mapping[:push_subscriptions][old_subscription.id] = new_subscription.id end { count: mapping[:push_subscriptions].size } end end def fix_links step("Fixing links", "Fixed %{count} links in %{duration}") do mapping[:fixed_links] ||= {} ActionText::RichText.where(id: mapping[:rich_text]&.values).find_each do |rich_text| fragment = rich_text.body.fragment fixed_link = false fragment.find_all("a[href]").each do |link| url = link["href"] uri = URI.parse(url) rescue nil if uri uri.host = FIX_LINK_HOSTS[uri.host] if uri.absolute? && FIX_LINK_HOSTS.key?(uri.host) params = Rails.application.routes.recognize_path(uri.path) rescue {} if params[:controller] == "cards" && params[:action] == "show" && params[:id] && mapping[:cards][params[:id].to_i] uri.path = Rails.application.routes.url_helpers.card_path(mapping[:cards][params[:id].to_i]) elsif params[:controller] == "boards" && params[:action] == "show" && params[:id] && mapping[:boards][params[:id].to_i] uri.path = Rails.application.routes.url_helpers.board_path(mapping[:boards][params[:id].to_i]) end link["href"] = uri.to_s mapping[:fixed_links][url] = link["href"] fixed_link = true end end rich_text.update!(body: fragment.to_html) if fixed_link end { count: mapping[:fixed_links].size } end end def import @import ||= Models.new(db_path) rescue => e $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] raise "Couldn't open the given database: #{e}" end def untenanted @untenanted ||= Models.new(untenanted_db_path) rescue => e $stderr.puts e.backtrace.join("\n") if ENV["DEBUG"] raise "Couldn't open the given untenanted database: #{e}" 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 @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 identities @identities ||= Class.new(application_record) do self.table_name = "identities" end end def memberships @memberships ||= begin models = self Class.new(application_record) do self.table_name = "memberships" def identity @identity ||= models.identities.find_by(id: identity_id) end end end end def accounts @accounts ||= Class.new(application_record) do self.table_name = "accounts" end end def account_join_codes @account_join_codes ||= Class.new(application_record) do self.table_name = "account_join_codes" end end def users @users ||= begin models = self Class.new(application_record) do self.table_name = "users" def settings @settings ||= models.user_settings.find_by(user_id: id) end end end end def boards @boards ||= begin models = self Class.new(application_record) do self.table_name = "boards" def publication @publication ||= models.board_publications.find_by(board_id: id) end end end end def columns @columns ||= Class.new(application_record) do self.table_name = "columns" end end def cards @cards ||= begin models = self Class.new(application_record) do self.table_name = "cards" def activity_spike @activity_spike ||= models.card_activity_spikes.find_by(card_id: id) end def engagement @engagement ||= models.card_engagements.find_by(card_id: id) end def goldness @goldness ||= models.card_goldnesses.find_by(card_id: id) end def not_now @not_now ||= models.card_not_nows.find_by(card_id: id) end def assignments models.assignments.where(card_id: id) end def closure @closure ||= models.closures.find_by(card_id: id) end end end end def comments @comments ||= Class.new(application_record) do self.table_name = "comments" end end def steps @steps ||= Class.new(application_record) do self.table_name = "steps" end end def reactions @reactions ||= Class.new(application_record) do self.table_name = "reactions" end end def tags @tags ||= Class.new(application_record) do self.table_name = "tags" end end def taggings @taggings ||= Class.new(application_record) do self.table_name = "taggings" end end def watches @watches ||= Class.new(application_record) do self.table_name = "watches" end end def pins @pins ||= Class.new(application_record) do self.table_name = "pins" end end def webhooks @webhooks ||= Class.new(application_record) do self.table_name = "webhooks" end end def webhook_deliveries @webhook_deliveries ||= Class.new(application_record) do self.table_name = "webhook_deliveries" end end def webhook_delinquency_trackers @webhook_delinquency_trackers ||= Class.new(application_record) do self.table_name = "webhook_delinquency_trackers" end end def push_subscriptions @push_subscriptions ||= Class.new(application_record) do self.table_name = "push_subscriptions" end end def assignments @assignments ||= Class.new(application_record) do self.table_name = "assignments" end end def closures @closures ||= Class.new(application_record) do self.table_name = "closures" end end def accesses @accesses ||= Class.new(application_record) do self.table_name = "accesses" end end def events @events ||= Class.new(application_record) do self.table_name = "events" end end def rich_texts @rich_texts ||= Class.new(application_record) do self.table_name = "action_text_rich_texts" end end def attachments @attachments ||= Class.new(application_record) do self.table_name = "active_storage_attachments" end end def blobs @blobs ||= Class.new(application_record) do self.table_name = "active_storage_blobs" end end def variant_records @variant_records ||= Class.new(application_record) do self.table_name = "active_storage_variant_records" end end def user_settings @user_settings ||= Class.new(application_record) do self.table_name = "user_settings" end end def board_publications @board_publications ||= Class.new(application_record) do self.table_name = "board_publications" end end def card_activity_spikes @card_activity_spikes ||= Class.new(application_record) do self.table_name = "card_activity_spikes" end end def card_engagements @card_engagements ||= Class.new(application_record) do self.table_name = "card_engagements" end end def card_goldnesses @card_goldnesses ||= Class.new(application_record) do self.table_name = "card_goldnesses" end end def card_not_nows @card_not_nows ||= Class.new(application_record) do self.table_name = "card_not_nows" end end def mentions @mentions ||= Class.new(application_record) do self.table_name = "mentions" end end def notifications @notifications ||= Class.new(application_record) do self.table_name = "notifications" end end def notification_bundles @notification_bundles ||= Class.new(application_record) do self.table_name = "notification_bundles" end end def entropies @entropies ||= Class.new(application_record) do self.table_name = "entropies" end end def filters @filters ||= Class.new(application_record) do self.table_name = "filters" end end def assignees_filters @assignees_filters ||= Class.new(application_record) do self.table_name = "assignees_filters" end end def assigners_filters @assigners_filters ||= Class.new(application_record) do self.table_name = "assigners_filters" end end def boards_filters @boards_filters ||= Class.new(application_record) do self.table_name = "boards_filters" end end def closers_filters @closers_filters ||= Class.new(application_record) do self.table_name = "closers_filters" end end def creators_filters @creators_filters ||= Class.new(application_record) do self.table_name = "creators_filters" end end def filters_tags @filters_tags ||= Class.new(application_record) do self.table_name = "filters_tags" end end end options = { skip_already_imported: false } parser = OptionParser.new do |parser| parser.banner = "Usage: #{$PROGRAM_NAME} [options] ..." parser.on("--untenanted-db-path PATH", "Path to the untenanted database") do |path| options[:untenanted_db_path] = path end parser.on("--skip-already-imported", "Skip import if account already exists") do options[:skip_already_imported] = true end parser.on("-h", "--help", "Show this help message") do puts parser exit end end parser.parse! untenanted_db_path = options[:untenanted_db_path] tenanted_db_paths = ARGV if untenanted_db_path.nil? $stderr.puts "Error: --untenanted-db-path is required" $stderr.puts $stderr.puts parser exit 1 end if tenanted_db_paths.empty? $stderr.puts "Error: at least one tenanted database path is required" $stderr.puts $stderr.puts parser exit 1 end total_imported = 0 duration = ActiveSupport::Benchmark.realtime do tenanted_db_paths.each_with_index do |db_path, index| puts puts "="*80 puts "Processing database #{index + 1}/#{tenanted_db_paths.size}: #{db_path}" puts "="*80 Import.new(db_path, untenanted_db_path, skip_already_imported: options[:skip_already_imported]).import_database total_imported += 1 end end puts puts "="*80 puts "Summary:" puts " Imported: #{total_imported}" puts " Total time: #{duration.round(2)} seconds" puts "="*80 ================================================ FILE: script/load-prod-db-in-dev.rb ================================================ #!/usr/bin/env ruby if ARGV.length != 1 puts "Usage: #{$0} " exit 1 end original_dbfile = ARGV[0] require "securerandom" identifier = SecureRandom.hex(4) # run a process to run the migration and dump the schema cache Process.fork do require_relative "../config/environment" unless Rails.env.local? abort "This script should only be run in a local development environment." end tenant = ActiveRecord::FixtureSet.identify(identifier) config = ApplicationRecord.tenanted_root_config path = config.config_adapter.path_for(config.database_for(tenant)) FileUtils.mkdir_p(File.dirname(path), verbose: true) FileUtils.cp original_dbfile, path, verbose: true puts "Running migrations..." system "bin/rails db:migrate" end Process.wait # now load the schema cache and do what we need to do in the database require_relative "../config/environment" tenant = ActiveRecord::FixtureSet.identify(identifier) ApplicationRecord.with_tenant(tenant) do |tenant| Current.account.destroy! Account.create_with_owner \ account: { name: "Company #{identifier}" }, owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com" } user = User.find_by(role: :owner) identity = Identity.find_or_create_by(email_address: user.email_address) identity.link_to(user.tenant) Board.find_each do |board| board.accesses.grant_to(user) end url = Rails.application.routes.url_helpers.root_url(Rails.application.config.action_controller.default_url_options.merge(script_name: Current.account.slug)) puts "\n\nLogin to #{url} as #{user.email_address} / secret123456" end ================================================ FILE: script/maintenance/fix_cross_account_taggings.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" cross_account_taggings = Tagging.joins(:tag).where("taggings.account_id != tags.account_id") puts "Found #{cross_account_taggings.count} cross-account taggings to fix" cross_account_taggings.find_each do |tagging| correct_tag = tagging.account.tags.find_or_create_by!(title: tagging.tag.title) tagging.update!(tag: correct_tag) puts "Fixed tagging #{tagging.id}: reassigned to tag #{correct_tag.id}" end puts "Done!" ================================================ FILE: script/maintenance/remove_duplicated_search_queries.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| User.find_each do |user| search_queries = Set.new to_delete = [] user.search_queries.find_each do |search_query| if search_queries.include?(search_query.terms) to_delete << search_query end search_queries << search_query.terms end to_delete.each(&:destroy) end end ================================================ FILE: script/maintenance/remove_duplicated_tags.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| Account.find_each do |account| tags_grouped_by_title = account.tags.group_by { |tag| tag.title.downcase } tags_grouped_by_title.each do |title, tags| if tags.length > 1 to_keep, to_merge = tags.first, tags[1..] to_merge.each do |tag_to_merge| tag_to_merge.cards.each do |card| to_keep.cards << card unless to_keep.cards.include?(card) end tag_to_merge.destroy end end end end end ================================================ FILE: script/migrations/20250924-populate-identities.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" ApplicationRecord.with_each_tenant do |tenant| puts "# #{tenant}" User.find_each do |user| next if user.system? || !user.active? if user.membership.present? puts "Found identity #{user.identity.id} for user #{user.id} (#{user.email_address})" else memberships = Membership.where(email_address: user.email_address) if memberships.empty? # Create a new Identity Identity.transaction do identity = Identity.create! user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name) puts "Created identity #{identity.id} for user #{user.id} (#{user.email_address})" end else # Merge this User's Membership into the existing Identity identity = memberships.first.identity user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Current.account.name) puts "Merged membership for user #{user.id} (#{user.email_address}) into identity #{identity.id}" end end end end ================================================ FILE: script/migrations/20251028-populate_membership_id_on_users.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" ApplicationRecord.with_each_tenant do |tenant| puts "🏢 #{tenant}" User.find_each do |user| next if user.system? || !user.active? if user.membership.present? puts "✅ User #{user.id} has a membership" else puts "⏩ Creating membership for user #{user.id}" identity = Identity.find_or_create_by(email_address: user.email_address) membership = identity.memberships.find_or_create_by(tenant: tenant) user.update_columns(membership_id: membership.id) end end end ================================================ FILE: script/migrations/20251029-populate-column-positions.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" ApplicationRecord.with_each_tenant do |tenant| puts "Processing tenant: #{tenant}" Board.find_each do |board| puts " Processing board: #{board.name} (ID: #{board.id})" columns = board.columns.order(:id) columns.each_with_index do |column, index| column.update_column(:position, index) puts " Set position #{index} for column '#{column.name}' (ID: #{column.id})" end end end puts "Migration completed!" ================================================ FILE: script/migrations/20251205-backfill-verified-at.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" BACKFILL_TIMESTAMP = Time.parse("2025-12-02 12:00:00 UTC") def collect_verified_user_ids verified_ids = Set.new # Owners (they created the account) verified_ids.merge(User.where(role: :owner).pluck(:id)) puts "After owners: #{verified_ids.size} users" # Card creators verified_ids.merge(Card.distinct.pluck(:creator_id).compact) puts "After card creators: #{verified_ids.size} users" # Comment creators verified_ids.merge(Comment.distinct.pluck(:creator_id).compact) puts "After comment creators: #{verified_ids.size} users" # Board creators verified_ids.merge(Board.distinct.pluck(:creator_id).compact) puts "After board creators: #{verified_ids.size} users" # Event creators verified_ids.merge(Event.distinct.pluck(:creator_id).compact) puts "After event creators: #{verified_ids.size} users" # Assigners (not assignees - they could be assigned without logging in) verified_ids.merge(Assignment.distinct.pluck(:assigner_id).compact) puts "After assigners: #{verified_ids.size} users" # Manual closers (user_id is nil for automatic closures) verified_ids.merge(Closure.where.not(user_id: nil).distinct.pluck(:user_id).compact) puts "After closers: #{verified_ids.size} users" # Manual postponers (user_id is nil for automatic entropy postponements) verified_ids.merge(Card::NotNow.where.not(user_id: nil).distinct.pluck(:user_id).compact) puts "After postponers: #{verified_ids.size} users" # Reactors verified_ids.merge(Reaction.distinct.pluck(:reacter_id).compact) puts "After reactors: #{verified_ids.size} users" # Filter creators verified_ids.merge(Filter.distinct.pluck(:creator_id).compact) puts "After filter creators: #{verified_ids.size} users" # Pinners verified_ids.merge(Pin.distinct.pluck(:user_id).compact) puts "After pinners: #{verified_ids.size} users" # Board accessors (accessed_at is touched when viewing boards) verified_ids.merge(Access.where.not(accessed_at: nil).distinct.pluck(:user_id).compact) puts "After board accessors: #{verified_ids.size} users" # Export requesters verified_ids.merge(Account::Export.distinct.pluck(:user_id).compact) puts "After export requesters: #{verified_ids.size} users" # Push subscribers verified_ids.merge(Push::Subscription.distinct.pluck(:user_id).compact) puts "After push subscribers: #{verified_ids.size} users" # Users who completed setup (name != email) verified_ids.merge( User.joins(:identity) .where.not("users.name = identities.email_address") .pluck(:id) ) puts "After setup completers: #{verified_ids.size} users" # Users whose identity has at least one session verified_ids.merge( User.where(identity_id: Session.distinct.select(:identity_id)).pluck(:id) ) puts "After identity sessions: #{verified_ids.size} users" verified_ids end puts "Collecting verified user IDs..." verified_user_ids = collect_verified_user_ids puts "\nFiltering to unverified users only..." users_to_update = User.where(id: verified_user_ids.to_a) .where(verified_at: nil) .where(active: true) .where.not(identity_id: nil) .where.not(role: :system) update_count = users_to_update.count puts "Found #{update_count} users to backfill" # Report remaining unverified users (before update) remaining_before = User.where(verified_at: nil, active: true) .where.not(identity_id: nil) .where.not(role: :system) .count remaining_after = remaining_before - update_count puts "\nCurrently unverified active users: #{remaining_before}" puts "After backfill, remaining unverified: #{remaining_after}" puts "These users will need to verify on next login." if update_count > 0 puts "\nBackfilling verified_at..." updated = users_to_update.update_all(verified_at: BACKFILL_TIMESTAMP) puts "Updated #{updated} users" end puts "\nDone!" ================================================ FILE: script/migrations/20260123-remove-draft-cards-from-search-index.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" total_deleted = 0 Account.find_each do |account| search_record_class = Search::Record.for(account.id) # Find search records for draft cards (both Card and Comment searchables) draft_card_ids = Card.where(account_id: account.id, status: "drafted").pluck(:id) if draft_card_ids.any? count = search_record_class.where(card_id: draft_card_ids).delete_all if count > 0 puts "#{account.name}: deleted #{count} search records for draft cards" total_deleted += count end end end puts "Migration completed! Total deleted: #{total_deleted}" ================================================ FILE: script/migrations/20260204-fix-misplaced-comment-events.rb ================================================ # Fix comment events that are on the wrong board after a card move. # # See https://github.com/basecamp/fizzy/pull/2486 # # Usage: # bin/rails runner script/migrations/20260204-fix-misplaced-comment-events.rb # dry run # bin/rails runner script/migrations/20260204-fix-misplaced-comment-events.rb --fix # actually fix # dry_run = !ARGV.include?("--fix") puts dry_run ? "DRY RUN - no changes will be made\n\n" : "FIXING misplaced events\n\n" misplaced_events = Event .where(eventable_type: "Comment") .joins("INNER JOIN comments ON comments.id = events.eventable_id") .joins("INNER JOIN cards ON cards.id = comments.card_id") .where("events.board_id != cards.board_id") total = misplaced_events.count puts "Found #{total} misplaced comment events\n\n" if total.zero? puts "Nothing to fix!" exit end fixed = 0 skipped = 0 misplaced_events.find_each.with_index do |event, index| comment = event.eventable card = comment&.card old_board = event.board new_board = card&.board puts "[#{index + 1}/#{total}] Event #{event.id}" if card.nil? || new_board.nil? puts " Skipping - orphaned data (comment or card deleted)" skipped += 1 puts next end puts " Card ##{card.number}: #{card.title.truncate(40)}" puts " Moving from board '#{old_board&.name || 'nil'}' to '#{new_board.name}'" if dry_run puts " (skipped - dry run)" else event.update!(board: new_board) fixed += 1 puts " Fixed!" end puts end puts "Done. #{dry_run ? "Run with --fix to apply changes." : "Fixed #{fixed} events."} (#{skipped} skipped)" ================================================ FILE: script/migrations/backfill-storage-ledger.rb ================================================ #!/usr/bin/env ruby # Backfill storage ledger with attach entries for all existing attachments. # # Run locally: # bin/rails runner script/migrations/backfill-storage-ledger.rb # # Run via Kamal: # kamal app exec -d -p --reuse "bin/rails runner script/migrations/backfill-storage-ledger.rb" # # Safe to re-run: skips attachments that already have entries (by blob_id + recordable). # # OPTIONAL: If you want to enforce no-reuse for direct attachments, verify there are # no existing violations (ActionText embeds may legitimately reuse blobs): # # ActiveStorage::Attachment # .joins(:blob) # .where(record_type: Storage::TRACKED_RECORD_TYPES) # .where.not(record_type: "ActionText::RichText") # .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID }) # .select(:blob_id) # .group(:blob_id) # .having("COUNT(*) > 1") # .count # # Should return empty hash if no direct-attachment reuse exists # # If reuse exists (excluding template blobs), fix the data first. class BackfillStorageLedger def run puts "Backfilling storage entries…" backfill_entries puts "\nMaterializing totals…" materialize_totals end private def backfill_entries created = 0 skipped = 0 ActiveStorage::Attachment.includes(:blob).find_each do |attachment| record = attachment.record.try(:storage_tracked_record) # Backfill creates one entry PER ATTACHMENT (not per blob) to match the ledger model. # Storage tracking is a business abstraction at the attachment level. # IMPORTANT: This assumes no historic blob reuse. Run pre-check query above first. if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id, recordable: record) skipped += 1 next end Storage::Entry.create! \ account_id: record.account.id, board_id: record.board_for_storage_tracking&.id, recordable_type: record.class.name, recordable_id: record.id, blob_id: attachment.blob_id, delta: attachment.blob.byte_size, operation: "attach" created += 1 print "." if created % 100 == 0 end puts "\n\nBackfill complete!" puts " Entries created: #{created}" puts " Attachments skipped: #{skipped}" end def materialize_totals boards_materialized = 0 accounts_materialized = 0 Board.find_each do |board| board.materialize_storage boards_materialized += 1 print "." if boards_materialized % 100 == 0 end Account.find_each do |account| account.materialize_storage accounts_materialized += 1 end puts "\n\nMaterialization complete!" puts " Boards: #{boards_materialized}" puts " Accounts: #{accounts_materialized}" end end BackfillStorageLedger.new.run ================================================ FILE: script/migrations/convert-absolute-attachment-urls-to-relative.rb ================================================ #!/usr/bin/env ruby # Convert absolute attachment URLs in rich text content to relative paths. # This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...) # making them portable across beta environments and host changes. # # MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption # # Run locally: # bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help # # Run via Kamal: # kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help" # # Safe to re-run: won't modify already-relative URLs class ConvertAbsoluteAttachmentUrlsToRelative # Match absolute URLs pointing to Active Storage routes, keeping the account slug ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\d+/rails/active_storage/[^"']+)} attr_reader :account, :dry_run def initialize(account_id: nil, dry_run: false) @account = Account.find_by(external_account_id: account_id) @dry_run = dry_run end def run puts "Converting absolute attachment URLs to relative paths" puts dry_run ? "DRY RUN MODE - no changes will be saved" : "LIVE MODE - changes will be saved" puts account ? "Only account: #{account.external_account_id} - #{account.name}" : "For **ALL ACCOUNTS**" puts "\nPress ENTER to continue running or CTRL-C to bail..." gets puts "\nRunning..." # Suppress SQL logs Rails.event.debug_mode = false seconds = Benchmark.realtime do suppressing_turbo_broadcasts do convert_urls end end puts "\n\n" puts "Finished in %.2f seconds." % seconds end private def suppressing_turbo_broadcasts Board.suppressing_turbo_broadcasts do Card.suppressing_turbo_broadcasts do yield end end end def convert_urls scanned = 0 fixed = 0 urls_converted = 0 action_texts_scope.find_each do |rich_text| scanned += 1 body = rich_text.body edited = false conversions = 0 body.send(:attachment_nodes).each do |node| url = node["url"] next unless url if url.match?(ABSOLUTE_URL_PATTERN) node["url"] = url.gsub(ABSOLUTE_URL_PATTERN, '\1') edited = true conversions += 1 end end if edited record = rich_text.record puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" unless dry_run rich_text.update! body: body.fragment.to_html end fixed += 1 urls_converted += conversions end end puts "\n\nConversion complete!" puts " Rich texts examined: #{scanned}" puts " Rich texts modified: #{fixed}" puts " URLs converted: #{urls_converted}" end def action_texts_scope # Only examine rich texts that have embedded attachments scope = ActionText::RichText.joins(:embeds_attachments) scope = scope.where(account: account) if account scope end end require "optparse" options = { account_id: nil, dry_run: true } OptionParser.new do |opts| opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" opts.on("-a", "--account ACCOUNT_ID", "Restrict to a specific account (external_account_id)") do |id| options[:account_id] = id end opts.on("--[no-]dry-run", "Run in dry-run mode (default: --dry-run)") do |v| options[:dry_run] = v end opts.on("-h", "--help", "Show this help message") do puts opts exit end end.parse! ConvertAbsoluteAttachmentUrlsToRelative.new(**options).run ================================================ FILE: script/migrations/convert-relative-attachment-urls-to-absolute.rb ================================================ #!/usr/bin/env ruby # Rollback script: Convert relative attachment URLs back to absolute URLs. # Use this if you need to rollback the relative URL changes and want to # convert rich texts created during the rollout back to absolute URLs. # # Run locally: # bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help # # Run via Kamal: # kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help" class ConvertRelativeAttachmentUrlsToAbsolute # Match relative URLs pointing to Active Storage routes (with account slug) RELATIVE_URL_PATTERN = %r{\A(/\d+/rails/active_storage/[^"']+)\z} attr_reader :host, :since def initialize(host:, since:) @host = host @since = since end def run puts "Converting relative attachment URLs to absolute URLs" puts "Host: #{host}" puts "Processing rich texts created since: #{since}" puts "\nPress ENTER to continue running or CTRL-C to bail..." gets puts "\nRunning..." seconds = Benchmark.realtime do suppressing_turbo_broadcasts do convert_urls end end puts "\n\n" puts "Finished in %.2f seconds." % seconds end private def suppressing_turbo_broadcasts Board.suppressing_turbo_broadcasts do Card.suppressing_turbo_broadcasts do yield end end end def convert_urls scanned = 0 fixed = 0 urls_converted = 0 action_texts_scope.find_each do |rich_text| scanned += 1 body = rich_text.body edited = false conversions = 0 body.send(:attachment_nodes).each do |node| url = node["url"] next unless url if url.match?(RELATIVE_URL_PATTERN) node["url"] = "#{host}#{url}" edited = true conversions += 1 end end if edited record = rich_text.record puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" rich_text.update! body: body.fragment.to_html fixed += 1 urls_converted += conversions end end puts "\n\nConversion complete!" puts " Rich texts examined: #{scanned}" puts " Rich texts modified: #{fixed}" puts " URLs converted: #{urls_converted}" end def action_texts_scope ActionText::RichText.joins(:embeds_attachments).where("action_text_rich_texts.created_at >= ?", since) end end require "optparse" require "time" options = {} OptionParser.new do |opts| opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" opts.on("--host HOST", "Host to prepend (e.g., https://app.fizzy.do)") do |host| options[:host] = host end opts.on("--since TIME", "Process rich texts created since this time (ISO 8601 format)") do |time| options[:since] = Time.parse(time) end opts.on("-h", "--help", "Show this help message") do puts opts exit end end.parse! if options[:host].nil? || options[:since].nil? puts "Error: --host and --since are required" puts "Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z" exit 1 end ConvertRelativeAttachmentUrlsToAbsolute.new(**options).run ================================================ FILE: script/migrations/copy-blobs-to-pure.rb ================================================ #! /usr/bin/env ruby require_relative "../config/environment" def migrate(source_service_name, target_service_name) ApplicationRecord.with_each_tenant do |tenant| puts "\n## #{tenant}" report = { updated: 0, skipped: 0, errors: 0 } if ActiveStorage::Blob.count == 0 puts "No blobs found, skipping." next end ActiveStorage::Blob.service = source_service = ActiveStorage::Blob.services.fetch(source_service_name) target_service = ActiveStorage::Blob.services.fetch(target_service_name) ActiveStorage::Blob.find_each do |blob| if target_service.name.to_sym == blob.service_name.to_sym report[:skipped] += 1 putc "-" elsif target_service.exist?(blob.key) report[:skipped] += 1 putc "S" else begin blob.open do |stream| target_service.upload(blob.key, stream, checksum: blob.checksum) end report[:updated] += 1 putc "." rescue ActiveStorage::FileNotFoundError report[:errors] += 1 putc "E" end end # Update the service name of the blob. blob.update_column :service_name, target_service_name end puts pp report end end migrate :local, :purestorage ================================================ FILE: script/migrations/fill_account_closure_reasons.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| Account.find_each do |account| account.send(:create_default_closure_reasons) end end ================================================ FILE: script/migrations/generate_comments_from_events.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| Card.find_each do |card| card.events.find_each do |event| Card::Eventable::SystemCommenter.new(card.reload, event).comment end end end ================================================ FILE: script/migrations/migrate-content-to-slugged-urls.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" domains = { "production" => "app.fizzy.do", "beta" => ENV.fetch("APP_FQDN", "beta1.fizzy-beta.com"), "staging" => "app.fizzy-staging.com" } def fix_attachments(rich_text) if rich_text.body rich_text.body.send(:attachment_nodes).each do |node| sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME) if sgid puts "Fixing attachment node: #{node.to_html}" model = sgid.model_class.find(sgid.model_id) node["sgid"] = model.attachable_sgid else puts "Skipping attachment node without valid sgid: #{node.to_html}" end end rich_text.save! end end ApplicationRecord.with_each_tenant do |tenant| account_id = Current.account.queenbee_id unless account_id puts "Skipping URL fixup for tenant: #{tenant}" next end puts "\n## Processing tenant: #{tenant}\n" domain = domains[Rails.env] || domains["production"] regex = %r{://\w+\.#{domain}/} pp [ Current.account.name, account_id, domain, regex ] puts Card.find_each do |card| puts "### Processing card #{card.id} in #{Rails.application.routes.url_helpers.board_card_path(card.board, card)}" fix_attachments(card.description) card.reload old_body = card.description.body.to_s if match = regex.match(old_body) puts "URL found in card #{card.id} in #{Rails.application.routes.url_helpers.board_card_path(card.board, card)}" new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/") card.description.update(body: new_body) || raise("Failed to update card description for card #{card.id}") end end Comment.find_each do |comment| puts "### Processing comment #{comment.id} in #{Rails.application.routes.url_helpers.board_card_path(comment.card.board, comment.card)}" fix_attachments(comment.body) comment.reload old_body = comment.body.body.to_s if match = regex.match(old_body) puts "URL found in comment #{comment.id} in #{Rails.application.routes.url_helpers.board_card_path(comment.card.board, comment.card)}" new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/") comment.body.update(body: new_body) || raise("Failed to update comment body for comment #{comment.id}") end end end ================================================ FILE: script/migrations/migrate-disk-service-blobs.rb ================================================ #! /usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| puts "\n## #{tenant}" report = { updated: 0, skipped: 0 } ActiveStorage::Blob.find_each do |blob| if blob.key.start_with?("#{tenant}/") report[:skipped] += 1 else blob.update_column :key, "#{tenant}/#{blob.key}" report[:updated] += 1 end end pp report disk_service = ActiveStorage::Blob.services.fetch(:local) new_root = File.join(disk_service.root, tenant) old_root = File.join("storage", "tenants", Rails.env, tenant, "files") FileUtils.mkdir_p(new_root, verbose: true) unless File.directory?(new_root) Dir.glob(File.join(old_root, "??")).each_slice(20) do |blob_dirs| FileUtils.mv(blob_dirs, new_root, verbose: true) end end ================================================ FILE: script/migrations/migrate_to_flat_card_urls.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" def replace_url(string) string.gsub(%r{/boards/\d+/cards/(\d+)}) do "/cards/#{$1}" end end def fix_rich_text(rich_text) original_html = rich_text.body_before_type_cast new_html = replace_url(original_html) if original_html != new_html rich_text.update_columns(body: new_html) end end ApplicationRecord.with_each_tenant do ActionText::RichText.where(record_type: "Card", name: "description").find_each do |rich_text| fix_rich_text rich_text end ActionText::RichText.where(record_type: "Comment", name: "body").find_each do |rich_text| fix_rich_text rich_text end end ================================================ FILE: script/migrations/migrate_to_new_cards_url_scheme.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" def replace_url(string) string.gsub(%r{/buckets/(\d+)/bubbles/(\d+)}) do "/boards/#{$1}/cards/#{$2}" end end ApplicationRecord.with_each_tenant do |tenant| Account.find_each do |account| Comment.find_each do |comment| comment.update!(body: replace_url(comment.body.content.to_s)) end end end ================================================ FILE: script/migrations/populate_columns_from_workflow_stages.rb ================================================ #!/usr/bin/env ruby require_relative "../../config/environment" class Card has_one :engagement, dependent: :destroy, class_name: "Card::Engagement" def doing? open? && published? && engagement&.status == "doing" end def on_deck? open? && published? && engagement&.status == "on_deck" end def considering? open? && published? && engagement.blank? end end ApplicationRecord.with_each_tenant do |tenant| puts "Processing tenant: #{tenant}" Column.destroy_all Board.find_each do |board| next unless board.workflow.present? # Map to track stage_id -> column columns_by_stage = {} # Create columns from workflow stages board.workflow.stages.find_each do |stage| column = board.columns.create!( name: stage.name, color: stage.color || Card::Colored::COLORS.first ) columns_by_stage[stage] = column puts "Created column '#{column.name}' for board '#{board.name}'" end # Associate cards with their corresponding columns based on stages board.cards.includes(:stage).find_each do |card| next if !card.doing? || card.stage.blank? unless card.stage.workflow.boards.include?(board) puts "Corrupt data: the card with id #{card.id} has the stage #{card.stage.name} with id #{card.stage.id} that belongs to a workflow not asociated ot its board" next end stage = columns_by_stage[card.stage] card.update!(column: stage) puts "Associated card ##{card.id} with column '#{stage.name}'" end end end puts "Migration completed!" ================================================ FILE: script/migrations/renaming/content.rb ================================================ #!/usr/bin/env ruby require "find" require "active_support/core_ext/string" # for camelize RENAME_RULES = { "bubble" => "card", "closed" => "closed", "poppable" => "closeable", "pop" => "closure", "bucket" => "board" } EXTENSIONS = %w[.rb .yml .html .js .css .erb] EXCLUDED_DIRS = %w[db .git script/renaming vendor/javascript] # Helper to build replacement regex patterns respecting case and separators def build_patterns(from, to) boundary = "(?<=\\A|[^a-zA-Z0-9])#{from}(?=[^a-zA-Z0-9]|\\z)" camel = from.camelize camel_plural = camel.pluralize underscore_plural = from.pluralize.underscore dasherized_plural = underscore_plural.dasherize [ # Match lowercase boundary-delimited [ /#{boundary}/, to ], # Match capitalized version (e.g., Bubble => Card) [ /(? cards(:logo)) [ /(? "card", "poppable" => "closeable", "closed" => "closed", "pop" => "closure", "bucket" => "board" }.freeze FILE_EXTENSIONS = %w[rb yml html css js jpg jpeg png gif svg erb].freeze def excluded_path?(path) EXCLUDED_DIRS.any? { |excluded| path.split(File::SEPARATOR).include?(excluded) } end def rename_path(path) new_path = path.dup RENAMES.each do |from, to| # Replace snake_case, kebab-case, plain, and CamelCase versions patterns = [ [ /(?<=\A|[^a-zA-Z0-9])#{from}(?=[^a-zA-Z0-9]|\z)/i, to ], [ from.camelize, to.camelize ], [ from.camelize(:lower), to.camelize(:lower) ], [ from.underscore.dasherize, to.underscore.dasherize ], [ from.underscore, to.underscore ] ] patterns.each do |pattern, replacement| new_path.gsub!(pattern, replacement) end end new_path end # Rename Directories First dirs = Dir.glob("**/*/").reject { |path| excluded_path?(path) }.sort_by { |dir| -dir.count("/") } puts "Renaming directories..." dirs.each do |dir| clean_dir = dir.chomp("/") new_dir = rename_path(clean_dir) next if clean_dir == new_dir next if File.exist?(new_dir) puts "Renaming dir: #{clean_dir} => #{new_dir}" FileUtils.mkdir_p(File.dirname(new_dir)) FileUtils.mv(clean_dir, new_dir) end # Rename Files files = Dir.glob("**/*.{#{FILE_EXTENSIONS.join(",")}}").reject { |path| excluded_path?(path) } puts "Renaming files..." files.each do |file| new_file = rename_path(file) next if file == new_file next if File.exist?(new_file) puts "Renaming file: #{file} => #{new_file}" FileUtils.mkdir_p(File.dirname(new_file)) FileUtils.mv(file, new_file) end puts "Renaming complete!" ================================================ FILE: script/migrations/reset_boards_ids.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| id_mapping = {} puts "Processing tenant: #{tenant}" # Disable foreign key constraints ApplicationRecord.connection.execute("PRAGMA foreign_keys = OFF;") begin # Get all boards ordered by ID boards = Board.order(:id).to_a # Create mapping of old IDs to new IDs boards.each_with_index do |board, index| id_mapping[board.id] = index + 1 end # Update foreign keys in related tables puts "Updating foreign keys in related tables..." # Update accesses table Access.where.not(board_id: nil).find_each do |access| if id_mapping[access.board_id] access.update_column(:board_id, id_mapping[access.board_id]) end end # Update cards table Card.where.not(board_id: nil).find_each do |card| if id_mapping[card.board_id] card.update_column(:board_id, id_mapping[card.board_id]) end end # Update boards_filters table (join table) ApplicationRecord.connection.execute("SELECT board_id FROM boards_filters").each do |row| old_id = row[0] if id_mapping[old_id] ApplicationRecord.connection.execute("UPDATE boards_filters SET board_id = #{id_mapping[old_id]} WHERE board_id = #{old_id}") end end # Update events table Event.where.not(board_id: nil).find_each do |event| if id_mapping[event.board_id] event.update_column(:board_id, id_mapping[event.board_id]) end end # Update events table (polymorphic relationship) Event.where(eventable_type: "Board").find_each do |event| if id_mapping[event.eventable_id] event.update_column(:eventable_id, id_mapping[event.eventable_id]) end end # Update mentions table (polymorphic relationship) Mention.where(source_type: "Board").find_each do |mention| if id_mapping[mention.source_id] mention.update_column(:source_id, id_mapping[mention.source_id]) end end # Update notifications table (polymorphic relationship) Notification.where(source_type: "Board").find_each do |notification| if id_mapping[notification.source_id] notification.update_column(:source_id, id_mapping[notification.source_id]) end end # Update action_text_markdowns table (polymorphic relationship) ActionText::RichText.where(record_type: "Board").find_each do |rich_text| if id_mapping[rich_text.record_id] rich_text.update_column(:record_id, id_mapping[rich_text.record_id]) end end # Update active_storage_attachments table (polymorphic relationship) ActiveStorage::Attachment.where(record_type: "Board").find_each do |attachment| if id_mapping[attachment.record_id] attachment.update_column(:record_id, id_mapping[attachment.record_id]) end end # Reset the boards table IDs puts "Resetting board IDs..." boards.each do |board| new_id = id_mapping[board.id] # Use direct SQL to update the ID to avoid ActiveRecord validations ApplicationRecord.connection.execute("UPDATE boards SET id = #{new_id} WHERE id = #{board.id}") end # Reset the SQLite sequence for the boards table ApplicationRecord.connection.execute("DELETE FROM sqlite_sequence WHERE name = 'boards'") max_id = Board.maximum(:id) || 0 ApplicationRecord.connection.execute("INSERT INTO sqlite_sequence (name, seq) VALUES ('boards', #{max_id})") puts "Board IDs have been reset successfully!" rescue => e puts "Error: #{e.message}" puts e.backtrace ensure # Re-enable foreign key constraints ApplicationRecord.connection.execute("PRAGMA foreign_keys = ON;") end # Update links in card descriptions and comment bodies Card.find_each do |card| description = card.description.content.dup description.gsub!(/boards\/(\d+)\//) do |match| old_id = $1.to_i new_id = id_mapping[old_id] new_id ? "boards/#{new_id}/" : match end if description != card.description.content puts "Updating links in card #{card.id}" card.update!(description: description) end end Comment.find_each do |comment| body = comment.body.content.dup body.gsub!(/boards\/(\d+)\//) do |match| old_id = $1.to_i new_id = id_mapping[old_id] new_id ? "boards/#{new_id}/" : match end if body != comment.body.content puts "Updating links in comment #{comment.id}" comment.update!(body: body) end end # Output the mapping of old IDs to new IDs puts "\nMapping of old IDs to new IDs:" puts id_mapping.inspect end ================================================ FILE: script/migrations/reset_cards_ids.rb ================================================ #!/usr/bin/env ruby require_relative "../config/environment" ApplicationRecord.with_each_tenant do |tenant| id_mapping = {} puts "Processing tenant: #{tenant}" # Disable foreign key constraints ApplicationRecord.connection.execute("PRAGMA foreign_keys = OFF;") begin # Get all cards ordered by ID cards = Card.order(:id).to_a # Create mapping of old IDs to new IDs cards.each_with_index do |card, index| id_mapping[card.id] = index + 1 end # Update foreign keys in related tables puts "Updating foreign keys in related tables..." # Update assignments table Assignment.where.not(card_id: nil).find_each do |assignment| if id_mapping[assignment.card_id] assignment.update_column(:card_id, id_mapping[assignment.card_id]) end end # Update card_engagements table Card::Engagement.where.not(card_id: nil).find_each do |engagement| if id_mapping[engagement.card_id] engagement.update_column(:card_id, id_mapping[engagement.card_id]) end end # Update card_goldnesses table Card::Goldness.where.not(card_id: nil).find_each do |goldness| if id_mapping[goldness.card_id] goldness.update_column(:card_id, id_mapping[goldness.card_id]) end end # Update closures table Closure.where.not(card_id: nil).find_each do |closure| if id_mapping[closure.card_id] closure.update_column(:card_id, id_mapping[closure.card_id]) end end # Update comments table Comment.where.not(card_id: nil).find_each do |comment| if id_mapping[comment.card_id] comment.update_column(:card_id, id_mapping[comment.card_id]) end end # Update pins table Pin.where.not(card_id: nil).find_each do |pin| if id_mapping[pin.card_id] pin.update_column(:card_id, id_mapping[pin.card_id]) end end # Update taggings table Tagging.where.not(card_id: nil).find_each do |tagging| if id_mapping[tagging.card_id] tagging.update_column(:card_id, id_mapping[tagging.card_id]) end end # Update watches table Watch.where.not(card_id: nil).find_each do |watch| if id_mapping[watch.card_id] watch.update_column(:card_id, id_mapping[watch.card_id]) end end # Update events table (polymorphic relationship) Event.where(eventable_type: "Card").find_each do |event| if id_mapping[event.eventable_id] event.update_column(:eventable_id, id_mapping[event.eventable_id]) end end # Update mentions table (polymorphic relationship) Mention.where(source_type: "Card").find_each do |mention| if id_mapping[mention.source_id] mention.update_column(:source_id, id_mapping[mention.source_id]) end end # Update notifications table (polymorphic relationship) Notification.where(source_type: "Card").find_each do |notification| if id_mapping[notification.source_id] notification.update_column(:source_id, id_mapping[notification.source_id]) end end # Update action_text_markdowns table (polymorphic relationship) ActionText::RichText.where(record_type: "Card").find_each do |rich_text| if id_mapping[rich_text.record_id] rich_text.update_column(:record_id, id_mapping[rich_text.record_id]) end end # Reset the cards table IDs puts "Resetting card IDs..." cards.each do |card| new_id = id_mapping[card.id] # Use direct SQL to update the ID to avoid ActiveRecord validations ApplicationRecord.connection.execute("UPDATE cards SET id = #{new_id} WHERE id = #{card.id}") end # Reset the SQLite sequence for the cards table ApplicationRecord.connection.execute("DELETE FROM sqlite_sequence WHERE name = 'cards'") max_id = Card.maximum(:id) || 0 ApplicationRecord.connection.execute("INSERT INTO sqlite_sequence (name, seq) VALUES ('cards', #{max_id})") puts "Card IDs have been reset successfully!" rescue => e puts "Error: #{e.message}" puts e.backtrace ensure # Re-enable foreign key constraints ApplicationRecord.connection.execute("PRAGMA foreign_keys = ON;") end Card.find_each do |card| description = card.description.content.dup description.gsub!(/cards\/(\d+)\)/) do |match| old_id = $1.to_i new_id = id_mapping[old_id] new_id ? "cards/#{new_id})" : match end if description != card.description.content puts "Updating links in card #{card.id}" card.update!(description: description) end end Comment.find_each do |comment| body = comment.body.content.dup body.gsub!(/cards\/(\d+)\)/) do |match| old_id = $1.to_i new_id = id_mapping[old_id] new_id ? "cards/#{new_id})" : match end if body != comment.body.content puts "Updating links in comment #{comment.id}" comment.update!(body: body) end end # Output the mapping of old IDs to new IDs puts "\nMapping of old IDs to new IDs:" puts id_mapping.inspect end ================================================ FILE: script/migrations/split-sibling-paragraphs-with-p-br.rb ================================================ #!/usr/bin/env ruby BACKFILL_TIMESTAMP = Time.parse("2025-12-19 00:07:00 UTC") ACCOUNT_ID = nil # restrict to an account_id # Split sibling

    tags with content by inserting

    to replicaate previous view. # Run for the time range before paragraphs were not spaced # See https://app.fizzy.do/5986089/cards/3472 # and https://github.com/basecamp/fizzy/pull/2107 # # MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption # # Run locally: # bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb # # Run via Kamal: # kamal app exec -d -p --reuse "bin/rails runner script/migrations/split-sibling-paragraphs-with-p-br.rb" # # Safe to re-run for a time range: won't re-detect unsplit paragraphs and updated_at will be outside time window class SeparateSiblingParagraphs attr_reader :updated_at, :account_id def initialize(updated_at, account_id: nil) @updated_at = updated_at @account_id = account_id end def run puts "Separating non-blank sibling paragraphs" puts "Updated at: #{updated_at}" puts account_id ? "Only account id: #{account_id}" : "For **ALL ACCOUNTS**" puts "\nPress ENTER to continue running or CTRL-C to bail..." gets puts "\nRunning..." # Suppress SQL logs Rails.event.debug_mode = false seconds = Benchmark.realtime do suppressing_turbo_broadcasts do separate_nonblank_paragraphs end end puts "\n\n" puts "Finished splitting non-blank

    s in %.2f seconds." % seconds end private def suppressing_turbo_broadcasts Board.suppressing_turbo_broadcasts do Card.suppressing_turbo_broadcasts do yield end end end def separate_nonblank_paragraphs scanned = 0 fixed = 0 insertions = 0 action_texts_scope.find_each(**batch_options) do |rich_text| next if account_id && rich_text.record.account.external_account_id != account_id scanned += 1 edited = false rich_text.body&.fragment.tap do |fragment| next unless fragment fragment.find_all("p + p").each do |node| unless empty_node?(node) || empty_node?(node.previous_sibling) node.add_previous_sibling empty_node_markup edited = true insertions += 1 end end if edited puts " - modifying #{rich_text.record.class.name} #{rich_text.record.to_param} (account: #{rich_text.record.account.external_account_id})" unless demo_card?(rich_text.record) # allow implicit touching to invalidate caches rich_text.update! body: fragment.to_html fixed +=1 end end end puts "\n\Separation complete!" puts " Rich texts examined: #{scanned}" puts " Rich texts modified: #{fixed}" puts " Paragraphs inserted: #{insertions}" fixed end def action_texts_scope ActionText::RichText.where(updated_at: updated_at) end def batch_options { batch_size: 20, order: :desc } end def empty_node?(node) node.to_html == empty_node_markup end def empty_node_markup "


    " end def demo_card?(record) record.is_a?(Card) && record.number <= 8 end end SeparateSiblingParagraphs.new(..BACKFILL_TIMESTAMP, account_id: ACCOUNT_ID).run ================================================ FILE: script/populate.rb ================================================ require_relative "../config/environment" require "faker" ACCOUNT = Account.find_by(name: "cleanslate") CARDS_COUNT = ARGV.first&.to_i || 10_000 BOARDS_COUNT = ARGV.second&.to_i || 100 TAGS_COUNT = ARGV.third&.to_i || 500 USERS_COUNT = ARGV.fourth&.to_i || 1000 Current.account = ACCOUNT Current.session = ACCOUNT.users.last.identity.sessions.first puts "Creating #{CARDS_COUNT} cards with #{TAGS_COUNT} tags across #{BOARDS_COUNT} board(s)" Board.suppressing_turbo_broadcasts do Card.suppressing_turbo_broadcasts do BOARDS_COUNT.times do ACCOUNT.boards.create! name: Faker::Company.buzzword, all_access: true print "." end CARDS_COUNT.times do card = ACCOUNT.boards.take.cards.create! \ title: Faker::Company.bs, description: Faker::Hacker.say_something_smart, status: :published print "." end TAGS_COUNT.times do ACCOUNT.cards.take.toggle_tag_with Faker::Game.title print "." end USERS_COUNT.times do ACCOUNT.users.create! name: Faker::FunnyName end end end ================================================ FILE: script/remove-lb-admin-production.sh ================================================ #!/usr/bin/env bash set -e ssh app@fizzy-lb-101.df-iad-int.37signals.com \ docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin ssh app@fizzy-lb-01.sc-chi-int.37signals.com \ docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin ssh app@fizzy-lb-401.df-ams-int.37signals.com \ docker exec fizzy-load-balancer kamal-proxy rm fizzy-admin ================================================ FILE: storage/.keep ================================================ ================================================ FILE: test/application_system_test_case.rb ================================================ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase browser_options = Selenium::WebDriver::Chrome::Options.new.tap do |opts| opts.add_argument("--window-size=1200,800") opts.add_argument("--disable-extensions") # Disable non-foreground tabs from getting a lower process priority opts.add_argument("--disable-renderer-backgrounding") # Normally, Chrome will treat a 'foreground' tab instead as backgrounded if the surrounding # window is occluded (aka visually covered) by another window. This flag disables that. opts.add_argument("--disable-backgrounding-occluded-windows") # Suppress all permission prompts by automatically denying them. opts.add_argument("--deny-permission-prompts") opts.add_argument("--enable-automation") end Capybara.register_driver :chrome_headless do |app| browser_options.add_argument("--headless") Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options) end Capybara.register_driver :chrome do |app| Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options) end if ENV["SYSTEM_TESTS_BROWSER"] driven_by :chrome, screen_size: [ 1200, 1000 ] else driven_by :chrome_headless, screen_size: [ 1200, 1000 ] end private def sign_in_as(user) visit session_transfer_url(user.identity.transfer_id, script_name: nil) assert_current_path root_path end end ================================================ FILE: test/channels/application_cable/connection_test.rb ================================================ require "test_helper" module ApplicationCable class ConnectionTest < ActionCable::Connection::TestCase setup do # Use non-37s account to assess that Current.account is set correctly @account = accounts(:initech) @session = sessions(:mike) end test "connects with valid session and account info" do cookies.signed[:session_token] = @session.signed_id connect "/cable", env: { "fizzy.external_account_id" => @account.external_account_id } assert_equal users(:mike), connection.current_user assert_equal @account, Current.account end test "rejects with invalid session token" do cookies.signed[:session_token] = "invalid-session-id" assert_reject_connection do connect "/cable", env: { "fizzy.external_account_id" => @account.external_account_id } end end test "rejects when account does not exist" do cookies.signed[:session_token] = @session.signed_id assert_reject_connection do connect "/cable", env: { "fizzy.external_account_id" => -1 } end end end end ================================================ FILE: test/controllers/account/cancellations_controller_test.rb ================================================ require "test_helper" class Account::CancellationsControllerTest < ActionDispatch::IntegrationTest setup do @account = accounts(:"37s") @user = users(:jason) sign_in_as @user if @account.respond_to?(:subscription) Account.any_instance.stubs(:subscription).returns(nil) end end test "an owner can cancel the account" do assert_difference -> { Account::Cancellation.count }, 1 do assert_enqueued_emails 1 do post account_cancellation_url end end assert_redirected_to session_menu_path(script_name: nil) assert_equal "Account deleted", flash[:notice] assert @account.reload.cancelled? assert_equal @user, @account.cancellation.initiated_by end test "non-owner cannot cancel the account" do logout_and_sign_in_as users(:david) assert_no_difference -> { Account::Cancellation.count } do post account_cancellation_url end assert_response :forbidden end test "cancelling an account while in single-tenant mode does nothing" do with_multi_tenant_mode(false) do assert_no_difference -> { Account::Cancellation.count } do post account_cancellation_url end assert_not @account.reload.cancelled? end end end ================================================ FILE: test/controllers/accounts/entropies_controller_test.rb ================================================ require "test_helper" class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update" do put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } } assert_equal 7.days, entropies("37s_account").auto_postpone_period assert_redirected_to account_settings_path end test "update as JSON" do put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json assert_response :success assert_equal 7.days, entropies("37s_account").reload.auto_postpone_period assert_equal 7, @response.parsed_body["auto_postpone_period_in_days"] end test "update requires admin" do logout_and_sign_in_as :david put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } } assert_response :forbidden end test "update rejects invalid auto_postpone_period" do original_period = entropies("37s_account").auto_postpone_period put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } } assert_response :unprocessable_entity assert_equal original_period, entropies("37s_account").reload.auto_postpone_period end test "update as JSON rejects invalid auto_postpone_period" do original_period = entropies("37s_account").auto_postpone_period put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }, as: :json assert_response :unprocessable_entity assert_equal original_period, entropies("37s_account").reload.auto_postpone_period end test "update as JSON requires admin" do logout_and_sign_in_as :david put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json assert_response :forbidden end end ================================================ FILE: test/controllers/accounts/exports_controller_test.rb ================================================ require "test_helper" class Account::ExportsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :jason end test "create creates an export record and enqueues job" do assert_difference -> { Account::Export.count }, 1 do assert_enqueued_with(job: DataExportJob) do post account_exports_path end end assert_redirected_to account_settings_path assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice] end test "create associates export with current user" do post account_exports_path export = Account::Export.last assert_equal users(:jason), export.user assert_equal Current.account, export.account assert export.pending? end test "create rejects request when current export limit is reached" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do Account::Export.create!(account: Current.account, user: users(:jason)) end assert_no_difference -> { Account::Export.count } do post account_exports_path end assert_response :too_many_requests end test "create allows request when exports are older than one day" do Account::ExportsController::CURRENT_EXPORT_LIMIT.times do Account::Export.create!(account: Current.account, user: users(:jason), created_at: 2.days.ago) end assert_difference -> { Account::Export.count }, 1 do post account_exports_path end assert_redirected_to account_settings_path end test "show displays completed export with download link" do export = Account::Export.create!(account: Current.account, user: users(:jason)) export.build get account_export_path(export) assert_response :success assert_select "a#download-link" end test "show displays a warning if the export is missing" do get account_export_path("not-really-an-export") assert_response :success assert_select "h2", "Download Expired" end test "show does not allow access to another user's export" do export = Account::Export.create!(account: Current.account, user: users(:kevin)) export.build get account_export_path(export) assert_response :success assert_select "h2", "Download Expired" end test "create as JSON" do assert_difference -> { Account::Export.count }, 1 do assert_enqueued_with(job: DataExportJob) do post account_exports_path, as: :json end end assert_response :created body = @response.parsed_body assert body["id"].present? assert_equal "pending", body["status"] assert_nil body["download_url"] end test "show as JSON with completed export" do export = Account::Export.create!(account: Current.account, user: users(:jason)) export.build get account_export_path(export), as: :json assert_response :success body = @response.parsed_body assert_equal export.id, body["id"] assert_equal "completed", body["status"] assert body["download_url"].present? end test "show as JSON with pending export" do export = Account::Export.create!(account: Current.account, user: users(:jason)) get account_export_path(export), as: :json assert_response :success body = @response.parsed_body assert_equal "pending", body["status"] assert_nil body["download_url"] end test "show as JSON with missing export" do get account_export_path("nonexistent"), as: :json assert_response :not_found end test "create is forbidden for non-admin members" do logout_and_sign_in_as :david post account_exports_path assert_response :forbidden end test "show is forbidden for non-admin members" do logout_and_sign_in_as :david export = Account::Export.create!(account: Current.account, user: users(:jason)) export.build get account_export_path(export) assert_response :forbidden end end ================================================ FILE: test/controllers/accounts/join_codes_controller_test.rb ================================================ require "test_helper" class Account::JoinCodesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "reset" do get account_join_code_path assert_response :success assert_changes -> { Current.account.join_code.reload.code } do delete account_join_code_path assert_redirected_to account_join_code_path end end test "update" do get edit_account_join_code_path assert_response :success put account_join_code_path, params: { account_join_code: { usage_limit: 5 } } assert_equal 5, Current.account.join_code.reload.usage_limit assert_redirected_to account_join_code_path end test "show as JSON" do get account_join_code_path, as: :json assert_response :success body = @response.parsed_body assert body["code"].present? assert body.key?("usage_count") assert body.key?("usage_limit") assert body.key?("url") assert body.key?("active") end test "update as JSON" do put account_join_code_path, params: { account_join_code: { usage_limit: 5 } }, as: :json assert_response :no_content assert_equal 5, Current.account.join_code.reload.usage_limit end test "update as JSON with invalid data" do huge_number = "99999999999999999999999999999999999" put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } }, as: :json assert_response :unprocessable_entity end test "destroy as JSON" do assert_changes -> { Current.account.join_code.reload.code } do delete account_join_code_path, as: :json end assert_response :no_content end test "update requires admin" do logout_and_sign_in_as :david put account_join_code_path, params: { account_join_code: { usage_limit: 5 } } assert_response :forbidden end test "destroy requires admin" do logout_and_sign_in_as :david delete account_join_code_path assert_response :forbidden end test "update with extremely large usage_limit" do # A number larger than bigint max (2^63 - 1 = 9223372036854775807) huge_number = "99999999999999999999999999999999999" put account_join_code_path, params: { account_join_code: { usage_limit: huge_number } } assert_response :unprocessable_entity assert_select ".txt-negative", text: /cannot be larger than the population of the planet/ end end ================================================ FILE: test/controllers/accounts/settings_controller_test.rb ================================================ require "test_helper" class Account::SettingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get account_settings_path assert_response :success end test "update" do put account_settings_path, params: { account: { name: "New Account Name" } } assert_equal "New Account Name", Current.account.reload.name assert_redirected_to account_settings_path end test "update as JSON" do put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json assert_response :no_content assert_equal "New Account Name", Current.account.reload.name end test "update requires admin" do logout_and_sign_in_as :david put account_settings_path, params: { account: { name: "New Account Name" } } assert_response :forbidden end test "show as JSON" do get account_settings_path, as: :json assert_response :success assert_equal Current.account.name, @response.parsed_body["name"] assert_equal Current.account.cards_count, @response.parsed_body["cards_count"] assert_equal Current.account.entropy.auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"] end end ================================================ FILE: test/controllers/active_storage/direct_uploads_controller_test.rb ================================================ require "test_helper" class ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTest setup do @blob_params = { blob: { filename: "screenshot.png", byte_size: 12345, checksum: "GQ5SqLsM7ylnji0Wgd9wNC==", content_type: "image/png" } } end test "create" do sign_in_as :david post rails_direct_uploads_path, params: @blob_params, headers: bearer_token_header(identity_access_tokens(:davids_api_token).token), as: :json assert_response :success assert_includes response.parsed_body.keys, "direct_upload" end test "create with valid access token" do post rails_direct_uploads_path, params: @blob_params, headers: bearer_token_header(identity_access_tokens(:davids_api_token).token), as: :json assert_response :success assert_includes response.parsed_body.keys, "direct_upload" end test "create with read-only access token" do post rails_direct_uploads_path, params: @blob_params, headers: bearer_token_header(identity_access_tokens(:jasons_api_token).token), as: :json assert_response :unauthorized end test "create with invalid access token" do post rails_direct_uploads_path, params: @blob_params, headers: bearer_token_header("invalid_token"), as: :json assert_response :unauthorized end test "create unauthenticated" do post rails_direct_uploads_path, params: @blob_params, as: :json assert_response :redirect end test "create in another account is forbidden" do sign_in_as :david post rails_direct_uploads_path(script_name: "/#{ActiveRecord::FixtureSet.identify("initech")}"), params: @blob_params, as: :json assert_response :forbidden end test "create with valid access token in another account is forbidden" do post rails_direct_uploads_path(script_name: "/#{ActiveRecord::FixtureSet.identify("initech")}"), params: @blob_params, headers: bearer_token_header(identity_access_tokens(:davids_api_token).token), as: :json assert_response :forbidden end private def bearer_token_header(token) { "Authorization" => "Bearer #{token}" } end end ================================================ FILE: test/controllers/admin/mission_control_test.rb ================================================ require "test_helper" class Admin::MissionControlTest < ActionDispatch::IntegrationTest test "staff can access mission control jobs" do sign_in_as :david untenanted do get "/admin/jobs" end assert_response :success end test "non-staff cannot access mission control jobs" do sign_in_as :jz untenanted do get "/admin/jobs" end assert_response :forbidden end end ================================================ FILE: test/controllers/allow_browser_test.rb ================================================ require "test_helper" class AllowBrowserTest < ActionDispatch::IntegrationTest test "Baidu browser is allowed" do sign_in_as :kevin get cards_path, headers: { "User-Agent" => "Mozilla/5.0 (Linux; Android 7.0; HUAWEI NXT-AL10 Build/HUAWEINXT-AL10; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/48.0.2564.116 Mobile Safari/537.36 baidubrowser/7.9.12.0 (Baidu; P1 7.0)NULL" } assert_response :success end test "nonsense user agent with bot in name is allowed" do sign_in_as :kevin get cards_path, headers: { "User-Agent" => "TotallyFakeBot/1.0 (NonsenseBrowser; Testing)" } assert_response :success end test "nonsense user agent is allowed" do sign_in_as :kevin get cards_path, headers: { "User-Agent" => "just some random nonsense text" } assert_response :success end test "old Chrome browser is rejected with 406" do sign_in_as :kevin # Chrome 118 is below the modern threshold of Chrome 120 get cards_path, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118 Safari/537.36" } assert_response :not_acceptable end test "Google Image Proxy is allowed" do sign_in_as :kevin get cards_path, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)" } assert_response :success end test "Facebook/Twitter bot is allowed" do sign_in_as :kevin get cards_path, headers: { "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0" } assert_response :success end end ================================================ FILE: test/controllers/api/flat_json_params_test.rb ================================================ require "test_helper" class FlatJsonParamsTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update user role with flat JSON" do put user_role_path(users(:david)), params: { role: "admin" }, as: :json assert_response :no_content assert users(:david).reload.admin? end test "update notification settings with flat JSON" do logout_and_sign_in_as :david assert_changes -> { users(:david).reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do put notifications_settings_path, params: { bundle_email_frequency: "every_few_hours" }, as: :json end assert_response :no_content end test "update join code with flat JSON" do put account_join_code_path, params: { usage_limit: 5 }, as: :json assert_response :no_content assert_equal 5, Current.account.join_code.reload.usage_limit end test "update account settings with flat JSON" do put account_settings_path, params: { name: "New Name" }, as: :json assert_response :no_content assert_equal "New Name", Current.account.reload.name end test "update board entropy with flat JSON" do board = boards(:writebook) put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json assert_response :success assert_equal 90.days, board.entropy.reload.auto_postpone_period end test "update account entropy with flat JSON" do put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json assert_response :success assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period end test "create push subscription with flat JSON" do stub_dns_resolution("142.250.185.206") post user_push_subscriptions_path(users(:kevin)), params: { endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "key1", auth_key: "key2" }, as: :json assert_response :created end test "create card with flat JSON" do assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), params: { title: "Flat card", description: "

    Flat description

    " }, as: :json end assert_response :created card = Card.last assert_equal "Flat card", card.title assert_equal "Flat description", card.description.to_plain_text end test "update card with flat JSON" do card = cards(:logo) put card_path(card), params: { title: "Flat update", description: "

    Updated flat

    " }, as: :json assert_response :success card.reload assert_equal "Flat update", card.title assert_equal "Updated flat", card.description.to_plain_text end test "create board with flat JSON" do assert_difference -> { Board.count }, +1 do post boards_path, params: { name: "Flat board" }, as: :json end assert_response :created assert_equal "Flat board", Board.last.name end test "update board with flat JSON" do board = boards(:writebook) put board_path(board), params: { name: "Flat board", auto_postpone_period_in_days: 7, public_description: "

    Flat public desc

    " }, as: :json assert_response :no_content board.reload assert_equal "Flat board", board.name assert_equal 7.days, board.entropy.auto_postpone_period assert_equal "Flat public desc", board.public_description.to_plain_text end test "create column with flat JSON" do board = boards(:writebook) assert_difference -> { board.columns.count }, +1 do post board_columns_path(board), params: { name: "Flat Column" }, as: :json end assert_response :created assert_equal "Flat Column", Column.last.name end test "update column with flat JSON" do column = columns(:writebook_in_progress) put board_column_path(column.board, column), params: { name: "Flat Updated" }, as: :json assert_response :no_content assert_equal "Flat Updated", column.reload.name end test "create step with flat JSON" do card = cards(:logo) assert_difference -> { card.steps.count }, +1 do post card_steps_path(card), params: { content: "Flat step" }, as: :json end assert_response :created assert_equal "Flat step", Step.last.content end test "update step with flat JSON" do card = cards(:logo) step = card.steps.create!(content: "Original") put card_step_path(card, step), params: { content: "Flat updated" }, as: :json assert_response :success assert_equal "Flat updated", step.reload.content end test "create card reaction with flat JSON" do card = cards(:logo) assert_difference -> { card.reactions.count }, +1 do post card_reactions_path(card), params: { content: "🎉" }, as: :json end assert_response :created end test "create comment reaction with flat JSON" do comment = comments(:logo_agreement_kevin) assert_difference -> { comment.reactions.count }, +1 do post card_comment_reactions_path(comment.card, comment), params: { content: "👍" }, as: :json end assert_response :created end test "create access token with flat JSON" do assert_difference -> { identities(:kevin).access_tokens.count }, +1 do post my_access_tokens_path, params: { description: "Flat token", permission: "read" }, as: :json end assert_response :created assert_equal "Flat token", @response.parsed_body["description"] end test "update user with flat JSON" do put user_path(users(:david)), params: { name: "Flat Name" }, as: :json assert_response :no_content assert_equal "Flat Name", users(:david).reload.name end test "create webhook with flat JSON" do board = boards(:writebook) assert_difference -> { Webhook.count }, +1 do post board_webhooks_path(board), params: { name: "Flat Webhook", url: "https://example.com/flat", subscribed_actions: [ "card_published" ] }, as: :json end assert_response :created assert_equal "Flat Webhook", Webhook.last.name end test "update webhook with flat JSON" do webhook = webhooks(:active) patch board_webhook_path(webhook.board, webhook), params: { name: "Flat Updated", subscribed_actions: [ "card_published" ] }, as: :json assert_response :success assert_equal "Flat Updated", webhook.reload.name end test "create signup with flat JSON" do sign_out email = "flatjson-#{SecureRandom.hex(6)}@example.com" untenanted do assert_difference -> { Identity.count }, +1 do post signup_path, params: { email_address: email }, as: :json end end assert_response :created end test "complete signup with flat JSON" do signup = Signup.new(email_address: "flatjson-#{SecureRandom.hex(6)}@example.com", full_name: "Flat User") signup.create_identity || raise("Failed to create identity") logout_and_sign_in_as signup.identity untenanted do assert_difference -> { Account.count }, +1 do post signup_completion_path, params: { full_name: "Flat JSON User" }, as: :json end end assert_response :created end test "update user via join with flat JSON" do logout_and_sign_in_as :david post users_joins_path, params: { name: "Flat Join" }, as: :json assert_response :no_content assert_equal "Flat Join", users(:david).reload.name end private def stub_dns_resolution(*ips) dns_mock = mock("dns") dns_mock.stubs(:each_address).multiple_yields(*ips) Resolv::DNS.stubs(:open).yields(dns_mock) end end ================================================ FILE: test/controllers/api_test.rb ================================================ require "test_helper" class ApiTest < ActionDispatch::IntegrationTest setup do @davids_bearer_token = bearer_token_env(identity_access_tokens(:davids_api_token).token) @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end test "authenticate with user credentials" do identity = identities(:david) untenanted do post session_path(format: :json), params: { email_address: identity.email_address } assert_response :created pending_token = @response.parsed_body["pending_authentication_token"] assert pending_token.present? magic_link = MagicLink.last post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } assert_response :success assert @response.parsed_body["session_token"].present? end end test "logout with user credentials" do identity = identities(:david) untenanted do post session_path(format: :json), params: { email_address: identity.email_address } magic_link = MagicLink.last assert_difference -> { identity.sessions.count }, +1 do post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: @response.parsed_body["pending_authentication_token"] } end assert cookies[:session_token].present? assert_difference -> { identity.sessions.count }, -1 do delete session_path(format: :json) end assert_response :no_content assert_not cookies[:session_token].present? end end test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success end test "fail to authenticate with invalid access token" do get boards_path(format: :json), env: bearer_token_env("nonsense") assert_response :unauthorized end test "changing data requires a write-endowed access token" do post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @jasons_bearer_token assert_response :unauthorized post boards_path(format: :json), params: { board: { name: "My new board" } }, env: @davids_bearer_token assert_response :success end private def bearer_token_env(token) { "HTTP_AUTHORIZATION" => "Bearer #{token}" } end end ================================================ FILE: test/controllers/boards/columns/closeds_controller_test.rb ================================================ require "test_helper" class Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get board_columns_closed_path(boards(:writebook)) assert_response :success end test "show as JSON" do get board_columns_closed_path(boards(:writebook)), as: :json assert_response :success assert_kind_of Array, @response.parsed_body end end ================================================ FILE: test/controllers/boards/columns/not_nows_controller_test.rb ================================================ require "test_helper" class Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get board_columns_not_now_path(boards(:writebook)) assert_response :success end test "show as JSON" do get board_columns_not_now_path(boards(:writebook)), as: :json assert_response :success assert_kind_of Array, @response.parsed_body end end ================================================ FILE: test/controllers/boards/columns/streams_controller_test.rb ================================================ require "test_helper" class Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get board_columns_stream_path(boards(:writebook)) assert_response :success end test "show as JSON" do get board_columns_stream_path(boards(:writebook)), as: :json assert_response :success assert_kind_of Array, @response.parsed_body assert response.headers["X-Total-Count"].present?, "Expected X-Total-Count header" end end ================================================ FILE: test/controllers/boards/columns_controller_test.rb ================================================ require "test_helper" class Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get board_column_path(boards(:writebook), columns(:writebook_in_progress)) assert_response :success end test "create" do assert_difference -> { boards(:writebook).columns.count }, +1 do post board_columns_path(boards(:writebook)), params: { column: { name: "New Column" } }, as: :turbo_stream assert_response :success end assert_equal "New Column", boards(:writebook).columns.last.name end test "create refreshes adjacent columns" do board = boards(:writebook) post board_columns_path(board), params: { column: { name: "New Column" } }, as: :turbo_stream new_column = board.columns.find_by!(name: "New Column") new_column.adjacent_columns.each do |adjacent_column| assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end test "update" do column = columns(:writebook_in_progress) assert_changes -> { column.reload.name }, from: "In progress", to: "Updated Name" do put board_column_path(boards(:writebook), column), params: { column: { name: "Updated Name" } }, as: :turbo_stream assert_response :success end end test "destroy" do column = columns(:writebook_in_progress) adjacent_columns = column.adjacent_columns.to_a delete board_column_path(column.board, column), as: :turbo_stream assert_redirected_to board_path(column.board) end test "index as JSON" do board = boards(:writebook) get board_columns_path(board), as: :json assert_response :success assert_equal board.columns.count, @response.parsed_body.count end test "show as JSON" do column = columns(:writebook_in_progress) get board_column_path(column.board, column), as: :json assert_response :success assert_equal column.id, @response.parsed_body["id"] end test "create as JSON" do board = boards(:writebook) assert_difference -> { board.columns.count }, +1 do post board_columns_path(board), params: { column: { name: "New Column" } }, as: :json end assert_response :created assert_equal board_column_path(board, Column.last, format: :json), @response.headers["Location"] assert_equal "New Column", @response.parsed_body["name"] end test "update as JSON" do column = columns(:writebook_in_progress) put board_column_path(column.board, column), params: { column: { name: "Updated Name" } }, as: :json assert_response :no_content assert_equal "Updated Name", column.reload.name end test "destroy as JSON" do column = columns(:writebook_on_hold) assert_difference -> { column.board.columns.count }, -1 do delete board_column_path(column.board, column), as: :json end assert_response :no_content end end ================================================ FILE: test/controllers/boards/entropies_controller_test.rb ================================================ require "test_helper" class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @board = boards(:writebook) end test "update" do assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 90 } } assert_equal 90.days, @board.entropy.reload.auto_postpone_period assert_turbo_stream action: :replace, target: dom_id(@board, :entropy) end end test "update as JSON" do assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json assert_response :success assert_equal 90.days, @board.entropy.reload.auto_postpone_period assert_equal 90, @response.parsed_body["auto_postpone_period_in_days"] end end test "update requires board admin permission" do logout_and_sign_in_as :jz original_period = @board.entropy.auto_postpone_period put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 7 } } assert_response :forbidden assert_equal original_period, @board.entropy.reload.auto_postpone_period end test "update rejects invalid auto_postpone_period" do original_period = @board.entropy.auto_postpone_period put board_entropy_path(@board, format: :turbo_stream), params: { board: { auto_postpone_period_in_days: 1 } } assert_response :unprocessable_entity assert_equal original_period, @board.entropy.reload.auto_postpone_period end test "update as JSON rejects invalid auto_postpone_period" do original_period = @board.entropy.auto_postpone_period put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 1 } }, as: :json assert_response :unprocessable_entity assert_equal original_period, @board.entropy.reload.auto_postpone_period end test "update as JSON requires board admin permission" do logout_and_sign_in_as :jz original_period = @board.entropy.auto_postpone_period put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 7 } }, as: :json assert_response :forbidden assert_equal original_period, @board.entropy.reload.auto_postpone_period end end ================================================ FILE: test/controllers/boards/involvements_controller_test.rb ================================================ require "test_helper" class Boards::InvolvementsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update" do board = boards(:writebook) board.access_for(users(:kevin)).access_only! assert_changes -> { board.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do put board_involvement_path(board, involvement: "watching") end assert_response :success end test "update as JSON" do board = boards(:writebook) board.access_for(users(:kevin)).access_only! assert_changes -> { board.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do put board_involvement_path(board), params: { involvement: "watching" }, as: :json end assert_response :no_content end end ================================================ FILE: test/controllers/boards/publications_controller_test.rb ================================================ require "test_helper" class Boards::PublicationsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @board = boards(:writebook) end test "publish a board" do assert_not @board.published? assert_changes -> { @board.reload.published? }, from: false, to: true do post board_publication_path(@board, format: :turbo_stream) end assert_turbo_stream action: :replace, target: dom_id(@board, :publication) end test "unpublish a board" do @board.publish assert @board.published? assert_changes -> { @board.reload.published? }, from: true, to: false do delete board_publication_path(@board, format: :turbo_stream) end assert_turbo_stream action: :replace, target: dom_id(@board, :publication) end test "publish a board via JSON" do assert_not @board.published? assert_changes -> { @board.reload.published? }, from: false, to: true do post board_publication_path(@board), as: :json end assert_response :created body = @response.parsed_body @board.reload assert_equal @board.name, body["name"] assert_equal published_board_url(@board), body["public_url"] end test "unpublish a board via JSON" do @board.publish assert @board.published? assert_changes -> { @board.reload.published? }, from: true, to: false do delete board_publication_path(@board), as: :json end assert_response :no_content end test "publish requires board admin permission" do logout_and_sign_in_as :jz assert_not @board.published? post board_publication_path(@board, format: :turbo_stream) assert_response :forbidden assert_not @board.reload.published? end test "unpublish requires board admin permission" do logout_and_sign_in_as :jz @board.publish assert @board.published? delete board_publication_path(@board, format: :turbo_stream) assert_response :forbidden assert @board.reload.published? end end ================================================ FILE: test/controllers/boards_controller_test.rb ================================================ require "test_helper" class BoardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "new" do get new_board_path assert_response :success end test "show" do get board_path(boards(:writebook)) assert_response :success end test "invalidates page title cache when account updates" do get board_path(boards(:writebook)) etag = response.headers["ETag"] accounts("37s").update!(name: "Renamed Account") get board_path(boards(:writebook)), headers: { "If-None-Match" => etag } assert_response :success end test "create" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Remodel Punch List" } } end board = Board.last assert_redirected_to board_path(board) assert_includes board.users, users(:kevin) assert_equal "Remodel Punch List", board.name end test "edit" do get edit_board_path(boards(:writebook)) assert_response :success end test "edit renders 11-day auto-close option last on the knob" do get edit_board_path(boards(:writebook)) assert_response :success assert_select "input[type=radio][name='board[auto_postpone_period_in_days]']" do |options| assert_equal Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS.map(&:to_s), options.map { |option| option["value"] } assert_equal "11", options.last["value"] end end test "update" do patch board_path(boards(:writebook)), params: { board: { name: "Writebook bugs", all_access: false, auto_postpone_period_in_days: 7 }, user_ids: users(:kevin, :jz).pluck(:id) } assert_redirected_to edit_board_path(boards(:writebook)) assert_equal "Writebook bugs", boards(:writebook).reload.name assert_equal users(:kevin, :jz).sort, boards(:writebook).users.sort assert_equal 7.days, entropies(:writebook_board).auto_postpone_period assert_not boards(:writebook).all_access? end test "update redirects to root when user removes themselves from board" do board = boards(:writebook) patch board_path(board), params: { board: { name: "Updated name", all_access: false }, user_ids: users(:david, :jz).pluck(:id) } assert_redirected_to root_path assert_not board.reload.users.include?(users(:kevin)) end test "update board with granular permissions, submitting no user ids" do assert_not boards(:private).all_access? boards(:private).users = [ users(:kevin) ] boards(:private).save! patch board_path(boards(:private)), params: { board: { name: "Renamed" } } assert_redirected_to edit_board_path(boards(:private)) assert_equal "Renamed", boards(:private).reload.name assert_equal [ users(:kevin) ], boards(:private).users assert_not boards(:private).all_access? end test "update all access" do board = Current.set(account: accounts("37s"), session: sessions(:kevin), user: users(:kevin)) do Board.create! name: "New board", all_access: false end assert_equal [ users(:kevin) ], board.users patch board_path(board), params: { board: { name: "Bugs", all_access: true } } assert_redirected_to edit_board_path(board) assert board.reload.all_access? assert_equal accounts("37s").users.active.sort, board.users.sort end test "destroy" do board = boards(:writebook) delete board_path(board) assert_redirected_to root_path assert_raises(ActiveRecord::RecordNotFound) { board.reload } end test "non-admin cannot change all_access on board they don't own" do logout_and_sign_in_as :jz board = boards(:writebook) original_all_access = board.all_access patch board_path(board), params: { board: { all_access: !original_all_access } } assert_response :forbidden assert_equal original_all_access, board.reload.all_access end test "non-admin cannot change individual user accesses on board they don't own" do logout_and_sign_in_as :jz board = boards(:writebook) original_users = board.users.sort patch board_path(board), params: { board: { name: board.name }, user_ids: [ users(:jz).id ] } assert_response :forbidden assert_equal original_users, board.reload.users.sort end test "non-admin cannot change board name on board they don't own" do logout_and_sign_in_as :jz board = boards(:writebook) original_name = board.name patch board_path(board), params: { board: { name: "Hacked Board Name" } } assert_response :forbidden assert_equal original_name, board.reload.name end test "non-admin cannot destroy board they don't own" do logout_and_sign_in_as :jz board = boards(:writebook) delete board_path(board) assert_response :forbidden end test "disables select all/none buttons for non-privileged user" do logout_and_sign_in_as :jz assert_not users(:jz).can_administer_board?(boards(:writebook)) get edit_board_path(boards(:writebook)) assert_response :success assert_select "button[disabled]", text: "Select all" assert_select "button[disabled]", text: "Select none" end test "enables select all/none buttons for privileged user" do assert users(:kevin).can_administer_board?(boards(:writebook)) get edit_board_path(boards(:writebook)) assert_response :success assert_select "button:not([disabled])", text: "Select all" assert_select "button:not([disabled])", text: "Select none" end test "access toggle disabled state is cached correctly" do board = boards(:writebook) david = users(:david) with_actionview_partial_caching do # privileged user assert users(:kevin).can_administer_board?(board) get edit_board_path(board) assert_response :success assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])" # unprivileged user logout_and_sign_in_as :jz assert_not users(:jz).can_administer_board?(board) get edit_board_path(board) assert_response :success assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]" end end test "index as JSON" do get boards_path, as: :json assert_response :success assert_equal users(:kevin).boards.count, @response.parsed_body.count end test "index as JSON paginates and preserves recently-accessed order" do account = accounts("37s") kevin = users(:kevin) baseline_accessed_at = 3.days.ago.change(usec: 0) kevin.accesses.order(:id).each_with_index do |access, index| access.update!(accessed_at: baseline_accessed_at + index.seconds) end 200.times do |index| board = Board.create!( name: "Recent board #{index}", creator: kevin, account: account, all_access: false ) board.access_for(kevin).update!(accessed_at: baseline_accessed_at + (index + 1).minutes) end expected_ids = kevin.boards.ordered_by_recently_accessed.pluck(:id) actual_ids = [] next_page = boards_path(format: :json) page_count = 0 while next_page get next_page, as: :json assert_response :success page_count += 1 actual_ids.concat(@response.parsed_body.map { |board| board["id"] }) next_page = next_page_from_link_header(@response.headers["Link"]) end assert_equal expected_ids, actual_ids assert_operator page_count, :>, 1 end test "show as JSON" do get board_path(boards(:writebook)), as: :json assert_response :success assert_equal boards(:writebook).name, @response.parsed_body["name"] assert_equal boards(:writebook).auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"] end test "show as JSON includes public_url when published" do board = boards(:writebook) board.publish get board_path(board), as: :json assert_response :success assert_equal published_board_url(board), @response.parsed_body["public_url"] end test "show as JSON excludes public_url when not published" do board = boards(:writebook) assert_not board.published? get board_path(board), as: :json assert_response :success assert_nil @response.parsed_body["public_url"] end test "create as JSON" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "My new board" } }, as: :json end assert_response :created assert_equal board_path(Board.last, format: :json), @response.headers["Location"] assert_equal "My new board", @response.parsed_body["name"] end test "update as JSON" do board = boards(:writebook) put board_path(board), params: { board: { name: "Updated Name" } }, as: :json assert_response :no_content assert_equal "Updated Name", board.reload.name end test "destroy as JSON" do board = boards(:writebook) assert_difference -> { Board.count }, -1 do delete board_path(board), as: :json end assert_response :no_content end test "index avoids N+1 queries on creator and identity" do assert_queries_match(/FROM [`"]users[`"].* IN \(/, count: 1) do assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do get boards_path, as: :json assert_response :success end end json = @response.parsed_body first_board = json.first assert first_board["creator"].present? assert first_board["creator"]["email_address"].present? end private def next_page_from_link_header(link_header) url = link_header&.match(/<([^>]+)>;\s*rel="next"/)&.captures&.first URI.parse(url).request_uri if url end end ================================================ FILE: test/controllers/cards/assignments_controller_test.rb ================================================ require "test_helper" class Cards::AssignmentsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "new" do get new_card_assignment_path(cards(:logo)) assert_response :success end test "create" do assert_changes "cards(:logo).reload.assigned_to?(users(:david))", from: false, to: true do post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream assert_meta_replaced(cards(:logo)) end assert_changes "cards(:logo).reload.assigned_to?(users(:david))", from: true, to: false do post card_assignments_path(cards(:logo)), params: { assignee_id: users(:david).id }, as: :turbo_stream assert_meta_replaced(cards(:logo)) end end test "create as JSON" do card = cards(:logo) assert_not card.assigned_to?(users(:david)) post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json assert_response :no_content assert card.reload.assigned_to?(users(:david)) post card_assignments_path(card), params: { assignee_id: users(:david).id }, as: :json assert_response :no_content assert_not card.reload.assigned_to?(users(:david)) end private def assert_meta_replaced(card) assert_turbo_stream action: :replace, target: dom_id(card, :meta) end end ================================================ FILE: test/controllers/cards/boards_controller_test.rb ================================================ require "test_helper" class Cards::BoardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update changes card board" do card = cards(:logo) new_board = boards(:private) assert_not_equal new_board, card.board assert_changes -> { card.reload.board }, from: card.board, to: new_board do put card_board_path(card), params: { board_id: new_board.id } end assert_redirected_to card end test "update as JSON" do card = cards(:logo) new_board = boards(:private) assert_not_equal new_board, card.board put card_board_path(card), params: { board_id: new_board.id }, as: :json assert_response :no_content assert_equal new_board, card.reload.board end end ================================================ FILE: test/controllers/cards/closures_controller_test.rb ================================================ require "test_helper" class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) assert_changes -> { card.reload.closed? }, from: false, to: true do post card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end test "destroy" do card = cards(:shipping) assert_changes -> { card.reload.closed? }, from: true, to: false do delete card_closure_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end test "create as JSON" do card = cards(:logo) assert_not card.closed? post card_closure_path(card), as: :json assert_response :no_content assert card.reload.closed? end test "destroy as JSON" do card = cards(:shipping) assert card.closed? delete card_closure_path(card), as: :json assert_response :no_content assert_not card.reload.closed? end end ================================================ FILE: test/controllers/cards/comments/reactions_controller_test.rb ================================================ require "test_helper" class Cards::Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :david @comment = comments(:logo_agreement_jz) @card = @comment.card end test "index" do get card_comment_reactions_path(@card, @comment) assert_response :success end test "create" do assert_difference -> { @comment.reactions.count }, 1 do post card_comment_reactions_path(@comment.card, @comment, format: :turbo_stream), params: { reaction: { content: "Great work!" } } assert_turbo_stream action: :replace, target: dom_id(@comment, :reacting) end end test "destroy" do reaction = reactions(:david) assert_difference -> { @comment.reactions.count }, -1 do delete card_comment_reaction_path(@comment.card, @comment, reaction, format: :turbo_stream) assert_turbo_stream action: :remove, target: dom_id(reaction) end end test "non-owner cannot destroy reaction" do reaction = reactions(:kevin) assert_no_difference -> { @comment.reactions.count } do delete card_comment_reaction_path(@comment.card, @comment, reaction, format: :turbo_stream) assert_response :forbidden end end test "index as JSON" do get card_comment_reactions_path(@card, @comment), as: :json assert_response :success assert_equal @comment.reactions.count, @response.parsed_body.count end test "create as JSON" do assert_difference -> { @comment.reactions.count }, 1 do post card_comment_reactions_path(@card, @comment), params: { reaction: { content: "👍" } }, as: :json end assert_response :created assert_equal "👍", @response.parsed_body["content"] end test "destroy as JSON" do reaction = reactions(:david) assert_difference -> { @comment.reactions.count }, -1 do delete card_comment_reaction_path(@card, @comment, reaction), as: :json end assert_response :no_content end end ================================================ FILE: test/controllers/cards/comments_controller_test.rb ================================================ require "test_helper" class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do assert_difference -> { cards(:logo).comments.count }, +1 do post card_comments_path(cards(:logo)), params: { comment: { body: "Agreed." } }, as: :turbo_stream end assert_response :success end test "create on draft card is forbidden" do draft_card = boards(:writebook).cards.create!(status: :drafted, creator: users(:kevin)) assert_no_difference -> { draft_card.comments.count } do post card_comments_path(draft_card), params: { comment: { body: "This should be forbidden" } }, as: :json end assert_response :forbidden end test "update" do put card_comment_path(cards(:logo), comments(:logo_agreement_kevin)), params: { comment: { body: "I've changed my mind" } }, as: :turbo_stream assert_response :success assert_action_text "I've changed my mind", comments(:logo_agreement_kevin).reload.body end test "update another user's comment" do assert_no_changes -> { comments(:logo_agreement_jz).reload.body.to_s } do put card_comment_path(cards(:logo), comments(:logo_agreement_jz)), params: { comment: { body: "I've changed my mind" } }, as: :turbo_stream end assert_response :forbidden end test "index as JSON" do card = cards(:logo) get card_comments_path(card), as: :json assert_response :success assert_equal card.comments.count, @response.parsed_body.count end test "create as JSON" do card = cards(:logo) assert_difference -> { card.comments.count }, +1 do post card_comments_path(card), params: { comment: { body: "New comment" } }, as: :json end assert_response :created assert_equal card_comment_path(card, Comment.last, format: :json), @response.headers["Location"] assert_equal Comment.last.id, @response.parsed_body["id"] end test "create as JSON with custom created_at" do card = cards(:logo) custom_time = Time.utc(2024, 1, 15, 10, 30, 0) assert_difference -> { card.comments.count }, +1 do post card_comments_path(card), params: { comment: { body: "Backdated comment", created_at: custom_time } }, as: :json end assert_response :created assert_equal custom_time, Comment.last.created_at end test "show as JSON" do comment = comments(:logo_agreement_kevin) get card_comment_path(comment.card, comment), as: :json assert_response :success assert_equal comment.id, @response.parsed_body["id"] assert_equal comment.card.id, @response.parsed_body.dig("card", "id") assert_equal card_url(comment.card), @response.parsed_body.dig("card", "url") assert_equal card_comment_reactions_url(comment.card, comment), @response.parsed_body["reactions_url"] assert_equal card_comment_url(comment.card, comment), @response.parsed_body["url"] end test "create as JSON with flat params" do card = cards(:logo) assert_difference -> { card.comments.count }, +1 do post card_comments_path(card), params: { body: "Flat comment" }, as: :json end assert_response :created assert_equal "Flat comment", Comment.last.body.to_plain_text end test "update as JSON with flat params" do comment = comments(:logo_agreement_kevin) put card_comment_path(cards(:logo), comment), params: { body: "Flat update" }, as: :json assert_response :success assert_equal "Flat update", comment.reload.body.to_plain_text end test "update as JSON" do comment = comments(:logo_agreement_kevin) put card_comment_path(cards(:logo), comment), params: { comment: { body: "Updated comment" } }, as: :json assert_response :success assert_equal "Updated comment", comment.reload.body.to_plain_text end test "destroy as JSON" do comment = comments(:logo_agreement_kevin) delete card_comment_path(cards(:logo), comment), as: :json assert_response :no_content assert_not Comment.exists?(comment.id) end end ================================================ FILE: test/controllers/cards/drafts_controller_test.rb ================================================ require "test_helper" class Cards::DraftsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do card = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted) get card_draft_path(card) assert_response :success end test "show redirects to card when published" do card = cards(:logo) get card_draft_path(card) assert_redirected_to card end end ================================================ FILE: test/controllers/cards/goldnesses_controller_test.rb ================================================ require "test_helper" class Cards::GoldnessesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do assert_changes -> { cards(:text).reload.golden? }, from: false, to: true do post card_goldness_path(cards(:text)), as: :turbo_stream assert_card_container_rerendered(cards(:text)) end end test "destroy" do assert_changes -> { cards(:logo).reload.golden? }, from: true, to: false do delete card_goldness_path(cards(:logo)), as: :turbo_stream assert_card_container_rerendered(cards(:logo)) end end test "create as JSON" do card = cards(:text) assert_not card.golden? post card_goldness_path(card), as: :json assert_response :no_content assert card.reload.golden? end test "destroy as JSON" do card = cards(:logo) assert card.golden? delete card_goldness_path(card), as: :json assert_response :no_content assert_not card.reload.golden? end end ================================================ FILE: test/controllers/cards/images_controller_test.rb ================================================ require "test_helper" class Cards::ImagesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "destroy" do card = cards(:logo) card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") assert card.image.attached? delete card_image_path(card) assert_redirected_to card assert_not card.reload.image.attached? end test "destroy as JSON" do card = cards(:logo) card.image.attach(io: file_fixture("moon.jpg").open, filename: "moon.jpg") assert card.image.attached? delete card_image_path(card), as: :json assert_response :no_content assert_not card.reload.image.attached? end end ================================================ FILE: test/controllers/cards/not_nows_controller_test.rb ================================================ require "test_helper" class Cards::NotNowsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) assert_changes -> { card.reload.postponed? }, from: false, to: true do post card_not_now_path(card), as: :turbo_stream assert_card_container_rerendered(card) end end test "create as JSON" do card = cards(:logo) assert_not card.postponed? post card_not_now_path(card), as: :json assert_response :no_content assert card.reload.postponed? end end ================================================ FILE: test/controllers/cards/pins_controller_test.rb ================================================ require "test_helper" class Cards::PinsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do assert_changes -> { cards(:layout).pinned_by?(users(:kevin)) }, from: false, to: true do perform_enqueued_jobs do assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do post card_pin_path(cards(:layout)), as: :turbo_stream end end end assert_response :success end test "create as JSON" do card = cards(:layout) assert_not card.pinned_by?(users(:kevin)) post card_pin_path(card), as: :json assert_response :no_content assert card.reload.pinned_by?(users(:kevin)) end test "destroy" do assert_changes -> { cards(:shipping).pinned_by?(users(:kevin)) }, from: true, to: false do perform_enqueued_jobs do assert_turbo_stream_broadcasts([ users(:kevin), :pins_tray ], count: 1) do delete card_pin_path(cards(:shipping)), as: :turbo_stream end end end assert_response :success end test "destroy as JSON" do card = cards(:shipping) assert card.pinned_by?(users(:kevin)) delete card_pin_path(card), as: :json assert_response :no_content assert_not card.reload.pinned_by?(users(:kevin)) end end ================================================ FILE: test/controllers/cards/previews_controller_test.rb ================================================ require "test_helper" class Cards::PreviewsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get cards_previews_path(format: :turbo_stream) assert_response :success end end ================================================ FILE: test/controllers/cards/publishes_controller_test.rb ================================================ require "test_helper" class Cards::PublishesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) card.drafted! assert_changes -> { card.reload.published? }, from: false, to: true do post card_publish_path(card) end assert_redirected_to card.board end test "create as JSON" do card = cards(:logo) card.drafted! assert_changes -> { card.reload.published? }, from: false, to: true do post card_publish_path(card), as: :json end assert_response :created end test "create and add another" do card = cards(:logo) card.drafted! assert_changes -> { card.reload.published? }, from: false, to: true do assert_difference -> { Card.count }, +1 do post card_publish_path(card, creation_type: "add_another") end end new_card = Card.last assert new_card.drafted? assert_redirected_to card_draft_path(new_card) end end ================================================ FILE: test/controllers/cards/reactions_controller_test.rb ================================================ require "test_helper" class Cards::ReactionsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :david @card = cards(:logo) end test "index" do get card_reactions_path(@card) assert_response :success end test "new" do get new_card_reaction_path(@card) assert_response :success end test "create" do assert_difference -> { @card.reactions.count }, 1 do post card_reactions_path(@card, format: :turbo_stream), params: { reaction: { content: "Great work!" } } assert_turbo_stream action: :replace, target: dom_id(@card, :reacting) end end test "destroy" do reaction = reactions(:logo_card_david) assert_difference -> { @card.reactions.count }, -1 do delete card_reaction_path(@card, reaction, format: :turbo_stream) assert_turbo_stream action: :remove, target: dom_id(reaction) end end test "non-owner cannot destroy reaction" do reaction = reactions(:logo_card_kevin) assert_no_difference -> { @card.reactions.count } do delete card_reaction_path(@card, reaction, format: :turbo_stream) assert_response :forbidden end end test "index as JSON" do get card_reactions_path(@card), as: :json assert_response :success assert_equal @card.reactions.count, @response.parsed_body.count end test "create as JSON" do assert_difference -> { @card.reactions.count }, 1 do post card_reactions_path(@card), params: { reaction: { content: "👍" } }, as: :json end assert_response :created assert_equal "👍", @response.parsed_body["content"] end test "destroy as JSON" do reaction = reactions(:logo_card_david) assert_difference -> { @card.reactions.count }, -1 do delete card_reaction_path(@card, reaction), as: :json end assert_response :no_content end end ================================================ FILE: test/controllers/cards/readings_controller_test.rb ================================================ require "test_helper" class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do freeze_time assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do post card_reading_url(cards(:logo)), as: :turbo_stream end end assert_response :success end test "read notification on card visit" do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do post card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end test "destroy" do freeze_time notifications(:logo_assignment_kevin).read assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do delete card_reading_url(cards(:logo)), as: :turbo_stream end end assert_response :success end test "create as JSON" do post card_reading_url(cards(:logo)), as: :json assert_response :created end test "destroy as JSON" do notifications(:logo_assignment_kevin).read delete card_reading_url(cards(:logo)), as: :json assert_response :no_content end test "unread notification on destroy" do notifications(:logo_assignment_kevin).read assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do delete card_reading_path(cards(:logo)), as: :turbo_stream end assert_response :success end end ================================================ FILE: test/controllers/cards/self_assignments_controller_test.rb ================================================ require "test_helper" class Cards::SelfAssignmentsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create assigns to current user" do card = cards(:layout) assert_not card.assigned_to?(users(:kevin)) post card_self_assignment_path(card), as: :turbo_stream assert_response :success assert_meta_replaced(card) assert card.reload.assigned_to?(users(:kevin)) end test "create toggles off when already assigned" do card = cards(:logo) assert card.assigned_to?(users(:kevin)) post card_self_assignment_path(card), as: :turbo_stream assert_response :success assert_meta_replaced(card) assert_not card.reload.assigned_to?(users(:kevin)) end test "create as JSON" do card = cards(:layout) assert_not card.assigned_to?(users(:kevin)) post card_self_assignment_path(card), as: :json assert_response :no_content assert card.reload.assigned_to?(users(:kevin)) end private def assert_meta_replaced(card) assert_turbo_stream action: :replace, target: dom_id(card, :meta) end end ================================================ FILE: test/controllers/cards/steps_controller_test.rb ================================================ require "test_helper" class Cards::StepsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) assert_difference -> { card.steps.count }, +1 do post card_steps_path(card), params: { step: { content: "Research alternatives" } }, as: :turbo_stream assert_turbo_stream action: :before, target: dom_id(card, :new_step) end assert_equal "Research alternatives", card.steps.last.content end test "update" do card = cards(:logo) step = card.steps.create!(content: "Original content") assert_changes -> { step.reload.content }, from: "Original content", to: "Updated content" do put card_step_path(card, step), params: { step: { content: "Updated content" } }, as: :turbo_stream assert_turbo_stream action: :replace, target: dom_id(step) end end test "destroy" do card = cards(:logo) step = card.steps.create!(content: "Step to delete") assert_difference -> { card.steps.count }, -1 do delete card_step_path(card, step), as: :turbo_stream assert_turbo_stream action: :remove, target: dom_id(step) end end test "toggle completion" do card = cards(:logo) step = card.steps.create!(content: "Test step", completed: false) # Toggle to completed assert_changes -> { step.reload.completed? }, from: false, to: true do put card_step_path(card, step), params: { step: { completed: "1" } }, as: :turbo_stream assert_turbo_stream action: :replace, target: dom_id(step) end # Toggle back to incomplete assert_changes -> { step.reload.completed? }, from: true, to: false do put card_step_path(card, step), params: { step: { completed: "0" } }, as: :turbo_stream assert_turbo_stream action: :replace, target: dom_id(step) end end test "index as JSON" do card = cards(:logo) card.steps.create!(content: "Step one") card.steps.create!(content: "Step two", completed: true) get card_steps_path(card), as: :json assert_response :success body = @response.parsed_body assert_equal 2, body.size assert_equal "Step one", body.first["content"] end test "create as JSON" do card = cards(:logo) assert_difference -> { card.steps.count }, +1 do post card_steps_path(card), params: { step: { content: "New step" } }, as: :json end assert_response :created assert_equal card_step_path(card, Step.last, format: :json), @response.headers["Location"] assert_equal "New step", @response.parsed_body["content"] end test "show as JSON" do card = cards(:logo) step = card.steps.create!(content: "Test step") get card_step_path(card, step), as: :json assert_response :success assert_equal step.id, @response.parsed_body["id"] assert_equal "Test step", @response.parsed_body["content"] end test "update as JSON" do card = cards(:logo) step = card.steps.create!(content: "Original") put card_step_path(card, step), params: { step: { content: "Updated" } }, as: :json assert_response :success assert_equal "Updated", step.reload.content assert_equal "Updated", @response.parsed_body["content"] end test "destroy as JSON" do card = cards(:logo) step = card.steps.create!(content: "To delete") delete card_step_path(card, step), as: :json assert_response :no_content assert_not Step.exists?(step.id) end end ================================================ FILE: test/controllers/cards/taggings_controller_test.rb ================================================ require "test_helper" class Cards::TaggingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "new" do get new_card_tagging_path(cards(:logo)) assert_response :success end test "toggle tag on" do assert_changes "cards(:logo).tagged_with?(tags(:mobile))", from: false, to: true do post card_taggings_path(cards(:logo)), params: { tag_title: tags(:mobile).title }, as: :turbo_stream assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags) end end test "toggle tag off" do assert_changes "cards(:logo).tagged_with?(tags(:web))", from: true, to: false do post card_taggings_path(cards(:logo)), params: { tag_title: tags(:web).title }, as: :turbo_stream assert_turbo_stream action: :replace, target: dom_id(cards(:logo), :tags) end end test "toggle tag on as JSON" do card = cards(:logo) assert_not card.tagged_with?(tags(:mobile)) post card_taggings_path(card), params: { tag_title: tags(:mobile).title }, as: :json assert_response :no_content assert card.reload.tagged_with?(tags(:mobile)) end test "toggle tag off as JSON" do card = cards(:logo) assert card.tagged_with?(tags(:web)) post card_taggings_path(card), params: { tag_title: tags(:web).title }, as: :json assert_response :no_content assert_not card.reload.tagged_with?(tags(:web)) end end ================================================ FILE: test/controllers/cards/triages_controller_test.rb ================================================ require "test_helper" class Cards::TriagesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) original_column = card.column column = columns(:writebook_in_progress) assert_changes -> { card.reload.column }, from: original_column, to: column do post card_triage_path(card, column_id: column.id) assert_redirected_to card end end test "destroy" do card = cards(:shipping) assert_changes -> { card.reload.column }, to: nil do delete card_triage_path(card), as: :turbo_stream assert_redirected_to card end end test "create as JSON" do card = cards(:logo) column = columns(:writebook_in_progress) post card_triage_path(card, column_id: column.id), as: :json assert_response :no_content assert_equal column, card.reload.column end test "destroy as JSON" do card = cards(:shipping) assert card.column.present? delete card_triage_path(card), as: :json assert_response :no_content assert_nil card.reload.column end end ================================================ FILE: test/controllers/cards/watches_controller_test.rb ================================================ require "test_helper" class Cards::WatchesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do cards(:logo).unwatch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: false, to: true do post card_watch_path(cards(:logo)), as: :turbo_stream end end test "destroy" do cards(:logo).watch_by users(:kevin) assert_changes -> { cards(:logo).watched_by?(users(:kevin)) }, from: true, to: false do delete card_watch_path(cards(:logo)), as: :turbo_stream end end test "create as JSON" do card = cards(:logo) card.unwatch_by users(:kevin) assert_not card.watched_by?(users(:kevin)) post card_watch_path(card), as: :json assert_response :no_content assert card.reload.watched_by?(users(:kevin)) end test "destroy as JSON" do card = cards(:logo) card.watch_by users(:kevin) assert card.watched_by?(users(:kevin)) delete card_watch_path(card), as: :json assert_response :no_content assert_not card.reload.watched_by?(users(:kevin)) end end ================================================ FILE: test/controllers/cards_controller_test.rb ================================================ require "test_helper" class CardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get cards_path assert_response :success end test "filtered index" do get cards_path(filters(:jz_assignments).as_params.merge(term: "haggis")) assert_response :success end test "create a new draft" do assert_difference -> { Card.count }, 1 do post board_cards_path(boards(:writebook)) end card = Card.last assert_redirected_to card_draft_path(card) assert card.drafted? end test "create resumes existing draft if it exists" do draft = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted) assert_no_difference -> { Card.count } do post board_cards_path(boards(:writebook)) assert_redirected_to card_draft_path(draft) end end test "show redirects to draft when card is drafted" do card = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted) get card_path(card) assert_redirected_to card_draft_path(card) end test "show renders assign-to-me hotkey using self assignment path" do card = cards(:logo) get card_path(card) assert_response :success assert_select "form[action=?] button[hidden]", card_self_assignment_path(card), text: "Assign to me" end test "show renders inline code in title" do card = cards(:logo) card.update_column :title, "Fix the `bug` in production" get card_path(card) assert_select ".card__title-link" do |element| assert_equal "Fix the bug in production", element.inner_html end end test "edit" do get edit_card_path(cards(:logo)) assert_response :success end test "edit card with invalid attachments in description" do card = cards(:logo) card.update! description: <<~HTML HTML get edit_card_path(card) assert_response :success end test "update" do patch card_path(cards(:logo)), as: :turbo_stream, params: { card: { title: "Logo needs to change", image: fixture_file_upload("moon.jpg", "image/jpeg"), description: "Something more in-depth" } } assert_response :success card = cards(:logo).reload assert_equal "Logo needs to change", card.title assert_equal "moon.jpg", card.image.filename.to_s assert_equal "Something more in-depth", card.description.to_plain_text.strip end test "update draft card does not render reactions" do draft = boards(:writebook).cards.create!(creator: users(:kevin), status: :drafted) patch card_path(draft), as: :turbo_stream, params: { card: { image: fixture_file_upload("moon.jpg", "image/jpeg") } } assert_response :success assert_no_match "reactions", response.body, "Draft card should not show reactions/boost button" end test "users can only see cards in boards they have access to" do get card_path(cards(:logo)) assert_response :success boards(:writebook).update! all_access: false boards(:writebook).accesses.revoke_from users(:kevin) get card_path(cards(:logo)) assert_response :not_found end test "admins can see delete button on any card" do get card_path(cards(:logo)) assert_response :success assert_match "Delete this card", response.body end test "card creators can see delete button on their own cards" do logout_and_sign_in_as :david get card_path(cards(:logo)) assert_response :success assert_match "Delete this card", response.body end test "non-admins cannot see delete button on cards they did not create" do logout_and_sign_in_as :jz get card_path(cards(:logo)) assert_response :success assert_no_match "Delete this card", response.body end test "non-admins cannot delete cards they did not create" do logout_and_sign_in_as :jz assert_no_difference -> { Card.count } do delete card_path(cards(:logo)) end assert_response :forbidden end test "card creators can delete their own cards" do logout_and_sign_in_as :david assert_difference -> { Card.count }, -1 do delete card_path(cards(:logo)) end assert_redirected_to boards(:writebook) end test "admins can delete any card" do assert_difference -> { Card.count }, -1 do delete card_path(cards(:logo)) end assert_redirected_to boards(:writebook) end test "show card with comment containing malformed remote image attachment" do card = cards(:logo) card.comments.create! \ creator: users(:kevin), body: '' get card_path(card) assert_response :success end test "show as JSON" do card = cards(:logo) card.steps.create!(content: "First step") card.steps.create!(content: "Second step", completed: true) get card_path(card), as: :json assert_response :success assert_equal card.title, @response.parsed_body["title"] assert_equal card.closed?, @response.parsed_body["closed"] assert_equal card.postponed?, @response.parsed_body["postponed"] assert_equal 2, @response.parsed_body["steps"].size assert_equal card_comments_url(card), @response.parsed_body["comments_url"] assert_equal card_reactions_url(card), @response.parsed_body["reactions_url"] end test "create as JSON" do assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), params: { card: { title: "My new card", description: "Big if true" } }, as: :json assert_response :created end card = Card.last assert_equal card_path(card, format: :json), @response.headers["Location"] assert_equal "My new card", @response.parsed_body["title"] assert_equal "My new card", card.title assert_equal "Big if true", card.description.to_plain_text end test "create as JSON with custom created_at" do custom_time = Time.utc(2024, 1, 15, 10, 30, 0) assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), params: { card: { title: "Backdated card", created_at: custom_time } }, as: :json assert_response :created end assert_equal custom_time, Card.last.created_at end test "create as JSON with custom last_active_at" do created_time = Time.utc(2024, 1, 15, 10, 30, 0) last_active_time = Time.utc(2024, 6, 1, 12, 0, 0) assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), params: { card: { title: "Card with activity", created_at: created_time, last_active_at: last_active_time } }, as: :json assert_response :created end card = Card.last assert_equal created_time, card.created_at assert_equal last_active_time, card.last_active_at end test "create as JSON defaults last_active_at to created_at when not provided" do created_time = Time.utc(2024, 1, 15, 10, 30, 0) assert_difference -> { Card.count }, +1 do post board_cards_path(boards(:writebook)), params: { card: { title: "Backdated card without last_active_at", created_at: created_time } }, as: :json assert_response :created end card = Card.last assert_equal created_time, card.created_at assert_equal created_time, card.last_active_at end test "update as JSON with custom last_active_at" do card = cards(:logo) custom_time = Time.utc(2024, 3, 15, 14, 0, 0) put card_path(card, format: :json), params: { card: { last_active_at: custom_time } } assert_response :success assert_equal custom_time, card.reload.last_active_at end test "update as JSON can restore last_active_at after comments overwrite it" do created_time = Time.utc(2024, 1, 15, 10, 30, 0) last_active_time = Time.utc(2024, 6, 1, 12, 0, 0) # Create a card with custom timestamps (simulating import) post board_cards_path(boards(:writebook)), params: { card: { title: "Imported card", created_at: created_time, last_active_at: last_active_time } }, as: :json assert_response :created card = Card.last # Adding a comment overwrites last_active_at (this is expected) card.comments.create!(creator: users(:kevin), body: "Imported comment") assert_not_equal last_active_time, card.reload.last_active_at # After import, restore the correct last_active_at put card_path(card, format: :json), params: { card: { last_active_at: last_active_time } } assert_response :success assert_equal last_active_time, card.reload.last_active_at end test "update as JSON" do card = cards(:logo) put card_path(card, format: :json), params: { card: { title: "Update test" } } assert_response :success assert_equal "Update test", card.reload.title end test "delete as JSON" do card = cards(:logo) delete card_path(card, format: :json) assert_response :no_content assert_not Card.exists?(card.id) end end ================================================ FILE: test/controllers/client_configurations_controller_test.rb ================================================ require "test_helper" class ClientConfigurationsControllerTest < ActionDispatch::IntegrationTest test "android" do assert_ok "/client_configurations/android_v1.json" end test "ios" do assert_ok "/client_configurations/ios_v1.json" end test "bad platform" do assert_no_route "/client_configurations/blackberry_v1.json" end test "bad version" do assert_no_route "/client_configurations/android_va.json" end test "nonexistent version" do assert_missing "/client_configurations/android_v2000.json" assert_missing "/client_configurations/ios_v2000.json" end private def assert_ok(url, cache_control: { public: true, max_age: "60" }) get url assert_response :ok assert_kind_of Hash, response.parsed_body["settings"] assert_kind_of Array, response.parsed_body["rules"] assert_equal cache_control, response.cache_control end def assert_no_route(url) without_action_dispatch_exception_handling do assert_raises(ActionController::RoutingError) { get url } end end def assert_missing(url) without_action_dispatch_exception_handling do assert_raises(ActionView::MissingTemplate) { get url } end end end ================================================ FILE: test/controllers/columns/cards/drops/closures_controller_test.rb ================================================ require "test_helper" class Columns::Cards::Drops::ClosuresControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) assert_changes -> { card.reload.closed? }, from: false, to: true do post columns_card_drops_closure_path(card), as: :turbo_stream assert_response :success end end end ================================================ FILE: test/controllers/columns/cards/drops/columns_controller_test.rb ================================================ require "test_helper" class Columns::Cards::Drops::ColumnsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) column = columns(:writebook_in_progress) assert_changes -> { card.reload.column }, to: column do post columns_card_drops_column_path(card, column_id: column.id), as: :turbo_stream assert_response :success end end end ================================================ FILE: test/controllers/columns/cards/drops/not_nows_controller_test.rb ================================================ require "test_helper" class Columns::Cards::Drops::NotNowsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:logo) assert_changes -> { card.reload.postponed? }, from: false, to: true do post columns_card_drops_not_now_path(card), as: :turbo_stream assert_response :success end end end ================================================ FILE: test/controllers/columns/cards/drops/streams_controller_test.rb ================================================ require "test_helper" class Columns::Cards::Drops::StreamsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do card = cards(:text) assert_changes -> { card.reload.triaged? }, from: true, to: false do post columns_card_drops_stream_path(card), as: :turbo_stream assert_response :success end end end ================================================ FILE: test/controllers/columns/left_positions_controller_test.rb ================================================ require "test_helper" class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "move column left" do board = boards(:writebook) columns = board.columns.sorted.to_a column_a = columns[0] column_b = columns[1] original_position_a = column_a.position original_position_b = column_b.position post column_left_position_path(column_b), as: :turbo_stream assert_response :success assert_equal original_position_b, column_a.reload.position assert_equal original_position_a, column_b.reload.position end test "move column left as JSON" do board = boards(:writebook) columns = board.columns.sorted.to_a column_a = columns[0] column_b = columns[1] original_position_a = column_a.position original_position_b = column_b.position post column_left_position_path(column_b), as: :json assert_response :created assert_equal original_position_b, column_a.reload.position assert_equal original_position_a, column_b.reload.position end test "move left refreshes adjacent columns" do column = columns(:writebook_in_progress) post column_left_position_path(column), as: :turbo_stream column.reload.adjacent_columns.each do |adjacent_column| assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end test "users can only reorder columns in boards they have access to" do column = columns(:writebook_in_progress) post column_left_position_path(column), as: :turbo_stream assert_response :success boards(:writebook).update! all_access: false boards(:writebook).accesses.revoke_from users(:kevin) post column_left_position_path(column), as: :turbo_stream assert_response :not_found end end ================================================ FILE: test/controllers/columns/right_positions_controller_test.rb ================================================ require "test_helper" class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "move column right" do board = boards(:writebook) columns = board.columns.sorted.to_a column_a = columns[0] column_b = columns[1] original_position_a = column_a.position original_position_b = column_b.position post column_right_position_path(column_a), as: :turbo_stream assert_response :success assert_equal original_position_b, column_a.reload.position assert_equal original_position_a, column_b.reload.position end test "move column right as JSON" do board = boards(:writebook) columns = board.columns.sorted.to_a column_a = columns[0] column_b = columns[1] original_position_a = column_a.position original_position_b = column_b.position post column_right_position_path(column_a), as: :json assert_response :created assert_equal original_position_b, column_a.reload.position assert_equal original_position_a, column_b.reload.position end test "move right refreshes adjacent columns" do column = columns(:writebook_in_progress) post column_right_position_path(column), as: :turbo_stream column.reload.adjacent_columns.each do |adjacent_column| assert_turbo_stream action: :replace, target: dom_id(adjacent_column) end end test "users can only reorder columns in boards they have access to" do column = columns(:writebook_triage) post column_right_position_path(column), as: :turbo_stream assert_response :success boards(:writebook).update! all_access: false boards(:writebook).accesses.revoke_from users(:kevin) post column_right_position_path(column), as: :turbo_stream assert_response :not_found end end ================================================ FILE: test/controllers/concerns/block_search_engine_indexing_test.rb ================================================ require "test_helper" class BlockSearchEngineIndexingTest < ActionDispatch::IntegrationTest test "sets X-Robots-Tag header to none on authenticated requests" do sign_in_as :david get board_path(boards(:writebook)) assert_response :success assert_equal "none", response.headers["X-Robots-Tag"] end test "sets X-Robots-Tag header to none on unauthenticated requests" do untenanted do get new_session_path end assert_response :success assert_equal "none", response.headers["X-Robots-Tag"] end test "sets X-Robots-Tag header to none on public board pages" do boards(:writebook).publish get public_board_path(boards(:writebook).publication.key) assert_response :success assert_equal "none", response.headers["X-Robots-Tag"] end end ================================================ FILE: test/controllers/concerns/current_timezone_test.rb ================================================ require "test_helper" class CurrentTimezoneTest < ActionDispatch::IntegrationTest test "includes the timezone cookie in the ETag" do cookies[:timezone] = "America/New_York" get user_avatar_path(users(:kevin)) etag = response.headers.fetch("ETag") get user_avatar_path(users(:kevin)), headers: { "If-None-Match" => etag } assert_equal 304, response.status cookies[:timezone] = "America/Los_Angeles" get user_avatar_path(users(:kevin)), headers: { "If-None-Match" => etag } assert_response :success assert_not_equal etag, response.headers.fetch("ETag") end end ================================================ FILE: test/controllers/concerns/request_forgery_protection_test.rb ================================================ require "test_helper" class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @original_allow_forgery_protection = ActionController::Base.allow_forgery_protection ActionController::Base.allow_forgery_protection = true @original_force_ssl = Rails.configuration.force_ssl @original_secure_protocol = ActionDispatch::Http::URL.secure_protocol end teardown do ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection Rails.configuration.force_ssl = @original_force_ssl ActionDispatch::Http::URL.secure_protocol = @original_secure_protocol end test "JSON request succeeds with missing Sec-Fetch-Site header" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Test Board" } }, as: :json end assert_response :created end test "HTTP request succeeds with missing Sec-Fetch-Site header when force_ssl is disabled" do Rails.configuration.force_ssl = false assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Test Board" } } end assert_response :redirect end test "HTTP request fails with missing Sec-Fetch-Site header when force_ssl is enabled" do Rails.configuration.force_ssl = true ActionDispatch::Http::URL.secure_protocol = true assert_no_difference -> { Board.count } do post boards_path, params: { board: { name: "Test Board" } } end assert_response :unprocessable_entity end test "HTTPS request fails with missing Sec-Fetch-Site header" do Rails.configuration.force_ssl = false assert_no_difference -> { Board.count } do post boards_path, params: { board: { name: "Test Board" } }, headers: { "X-Forwarded-Proto" => "https" } end assert_response :unprocessable_entity end end ================================================ FILE: test/controllers/concerns/set_platform_test.rb ================================================ require "test_helper" class SetPlatformTest < ActionDispatch::IntegrationTest DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0 bridge-components: [buttons overflow-menu]" test "uses the request user agent by default" do sign_in_as :david get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA } assert_select "body[data-platform='desktop web'][data-bridge-platform=''][data-bridge-components='']" end test "prefers x_user_agent cookie over request user agent" do sign_in_as :david cookies[:x_user_agent] = NATIVE_IOS_UA get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA } assert_select "body[data-platform='native ios'][data-bridge-platform='ios'][data-bridge-components='buttons overflow-menu']" end end ================================================ FILE: test/controllers/controller_authentication_test.rb ================================================ require "test_helper" class ControllerAuthenticationTest < ActionDispatch::IntegrationTest test "access without an account slug redirects to menu" do sign_in_as :kevin integration_session.default_url_options[:script_name] = "" # no tenant get cards_path assert_redirected_to session_menu_path end test "access with an account slug but no session redirects to new session" do get cards_path assert_redirected_to new_session_path(script_name: nil) end test "access with an account slug and a session allows functional access" do sign_in_as :kevin get cards_path assert_response :success end end ================================================ FILE: test/controllers/events/day_timeline/columns_controller_test.rb ================================================ require "test_helper" class Events::DayTimeline::ColumnsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show added column" do get events_day_timeline_column_path("added") assert_response :success assert_select "h1", text: /Added/ end test "show updated column" do get events_day_timeline_column_path("updated") assert_response :success assert_select "h1", text: /Updated/ end test "show closed column" do get events_day_timeline_column_path("closed") assert_response :success assert_select "h1", text: /Done/ end test "show returns not found for invalid column" do get events_day_timeline_column_path("invalid") assert_response :not_found end end ================================================ FILE: test/controllers/events_controller_test.rb ================================================ require "test_helper" class EventsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin travel_to Time.utc(2025, 1, 22, 17, 30, 0) events(:layout_assignment_jz).update!(created_at: Time.current.beginning_of_day + 8.hours) end test "index" do get events_path assert_select "div.events__time-block[style='grid-area: 17/2']" do assert_select "strong", text: /assigned JZ to Layout is broken/ end end test "index with a specific timezone" do cookies[:timezone] = "America/New_York" get events_path assert_select "div.events__time-block[style='grid-area: 22/2']" do assert_select "strong", text: /assigned JZ to Layout is broken/ end end test "only displays events from filtered boards" do get events_path(board_ids: [ boards(:writebook).id ]) assert_response :success events_shown = css_select(".event").count assert events_shown > 0, "Should show some events" css_select(".event").each do |event| assert_includes event.text, boards(:writebook).name end end end ================================================ FILE: test/controllers/filters_controller_test.rb ================================================ require "test_helper" class FiltersControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :david end test "create" do assert_difference "users(:david).filters.count", +1 do post filters_path, params: { indexed_by: "closed", assignment_status: "unassigned", tag_ids: [ tags(:mobile).id ], assignee_ids: [ users(:jz).id ], board_ids: [ boards(:writebook).id ] }, as: :turbo_stream end assert_response :success filter = Filter.last assert_predicate filter.indexed_by, :closed? assert_predicate filter.assignment_status, :unassigned? assert_equal [ tags(:mobile) ], filter.tags assert_equal [ users(:jz) ], filter.assignees assert_equal [ boards(:writebook) ], filter.boards end test "destroy" do filter = filters(:jz_assignments) expected_params = filter.as_params assert_difference "users(:david).filters.count", -1 do delete filter_path(filter), as: :turbo_stream end assert_response :success end end ================================================ FILE: test/controllers/join_codes_controller_test.rb ================================================ require "test_helper" class JoinCodesControllerTest < ActionDispatch::IntegrationTest setup do @account = accounts("37s") @join_code = account_join_codes(:"37s") end test "new" do get join_path(code: @join_code.code, script_name: @account.slug) assert_response :success assert_in_body "37signals" end test "new with an invalid code" do get join_path(code: "INVALID-CODE", script_name: @account.slug) assert_response :not_found end test "new with an inactive code" do @join_code.update!(usage_count: @join_code.usage_limit) get join_path(code: @join_code.code, script_name: @account.slug) assert_response :gone assert_in_body "That code is all used up" end test "create" do assert_difference -> { Identity.count }, 1 do assert_difference -> { User.count }, 1 do post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" } end end assert_redirected_to session_magic_link_url(script_name: nil) assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating] end test "create for existing identity" do identity = identities(:jz) sign_in_as :jz assert identity.users.exists?(account: @account), "JZ should be a member of 37s for this test" assert identity.users.find_by!(account: @account).setup?, "JZ's user should be setup for this test" assert_no_difference -> { Identity.count } do assert_no_difference -> { User.count } do post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: identity.email_address } end end assert_redirected_to landing_url(script_name: @account.slug) end test "create for signed-in identity without a user in the account redirects to verification" do identity = identities(:mike) sign_in_as :mike assert_not identity.users.exists?(account: @account), "Mike should not be a member of 37s for this test" assert_no_difference -> { Identity.count } do assert_difference -> { User.count }, 1 do post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: identity.email_address } end end assert_redirected_to new_users_verification_url(script_name: @account.slug) end test "create for different identity terminates existing session" do sign_in_as :kevin assert_difference -> { Identity.count }, 1 do assert_difference -> { User.count }, 1 do post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" } end end assert_redirected_to session_magic_link_url(script_name: nil) assert_not_predicate cookies[:session_token], :present? end test "create with invalid email address" do # Avoid Sentry exceptions when attackers try to stuff invalid emails into the system without_action_dispatch_exception_handling do assert_no_difference -> { Identity.count } do assert_no_difference -> { User.count } do post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "not-a-valid-email" } end end assert_response :unprocessable_entity end end test "create is rate limited" do Rails.cache.stubs(:increment).returns(11) post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "test@example.com" } assert_response :too_many_requests end end ================================================ FILE: test/controllers/landings_controller_test.rb ================================================ require "test_helper" class LandingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "redirects to the timeline when many boards" do get landing_path assert_redirected_to root_path end test "redirects to the timeline when no boards" do Board.destroy_all get landing_path assert_redirected_to root_path end test "redirects to boards when only one board" do sole_board, *boards_to_delete = users(:kevin).boards.to_a boards_to_delete.each(&:destroy) get landing_path assert_redirected_to board_path(sole_board) end end ================================================ FILE: test/controllers/my/access_tokens_controller_test.rb ================================================ require "test_helper" class My::AccessTokensControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create new token" do get my_access_tokens_path assert_response :success get new_my_access_token_path assert_response :success assert_changes -> { identities(:kevin).access_tokens.count }, +1 do post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } follow_redirect! assert_in_body identities(:kevin).access_tokens.last.token end end test "create new token via JSON with session" do assert_difference -> { identities(:kevin).access_tokens.count }, +1 do post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "write" } }, as: :json end assert_response :created body = @response.parsed_body assert body["id"].present? assert body["token"].present? assert_equal "Fizzy CLI", body["description"] assert_equal "write", body["permission"] assert body["created_at"].present? end test "create new token via JSON with bearer token" do sign_out bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } assert_difference -> { identities(:david).access_tokens.count }, +1 do post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "read" } }, env: bearer_token, as: :json end assert_response :created body = @response.parsed_body assert body["token"].present? assert_equal "Fizzy CLI", body["description"] assert_equal "read", body["permission"] end test "cannot create new token via JSON with read-only bearer token" do sign_out bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:jasons_api_token).token}" } assert_no_difference -> { identities(:jason).access_tokens.count } do post my_access_tokens_path, params: { access_token: { description: "Fizzy CLI", permission: "read" } }, env: bearer_token, as: :json end assert_response :unauthorized end test "index as JSON" do get my_access_tokens_path, as: :json assert_response :success body = @response.parsed_body assert_kind_of Array, body end test "index as JSON with bearer token and no account scope" do sign_out bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } untenanted do get my_access_tokens_path, as: :json, env: bearer_token end assert_response :success assert_kind_of Array, @response.parsed_body end test "destroy as JSON" do token = identities(:kevin).access_tokens.create!(description: "To delete", permission: "read") assert_difference -> { identities(:kevin).access_tokens.count }, -1 do delete my_access_token_path(token), as: :json end assert_response :no_content end test "accessing new token after reveal window redirects to index" do assert_changes -> { identities(:kevin).access_tokens.count }, +1 do post my_access_tokens_path, params: { access_token: { description: "GitHub", permission: "read" } } travel_to 15.seconds.from_now follow_redirect! assert_equal "Token is no longer visible", flash[:alert] end end end ================================================ FILE: test/controllers/my/identities_controller_test.rb ================================================ require "test_helper" class My::IdentitiesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show as JSON" do identity = identities(:kevin) expected_count = identity.users_with_active_accounts.count untenanted do get my_identity_path, as: :json assert_response :success assert_equal identity.id, @response.parsed_body["id"] assert_equal expected_count, @response.parsed_body["accounts"].count end end test "show as JSON includes users from active accounts only" do identity = identities(:kevin) active_account = Account.create!(external_account_id: 9999981, name: "Active Account") cancelled_account = Account.create!(external_account_id: 9999982, name: "Cancelled Account") identity.users.create!(account: active_account, name: "Kevin", role: :owner) cancelling_user = identity.users.create!(account: cancelled_account, name: "Kevin", role: :owner) cancelled_account.cancel(initiated_by: cancelling_user) untenanted do get my_identity_path, as: :json assert_response :success account_ids = @response.parsed_body["accounts"].map { |account| account["id"] } assert_includes account_ids, active_account.id assert_not_includes account_ids, cancelled_account.id end end end ================================================ FILE: test/controllers/my/menus_controller_test.rb ================================================ require "test_helper" class My::MenusControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @user = users(:kevin) @account = accounts("37s") end test "show" do get my_menu_path assert_response :success end test "etag invalidates when filters change" do get my_menu_path assert_response :success etag = response.headers["ETag"] @user.filters.create!( params_digest: Filter.digest_params({ indexed_by: :all, sorted_by: :newest }), fields: { indexed_by: :all, sorted_by: :newest } ) get my_menu_path, headers: { "If-None-Match" => etag } assert_response :success end test "etag invalidates when boards change" do get my_menu_path assert_response :success etag = response.headers["ETag"] @account.boards.create!(name: "New Board", all_access: true, creator: @user) get my_menu_path, headers: { "If-None-Match" => etag } assert_response :success end test "etag invalidates when tags change" do get my_menu_path assert_response :success etag = response.headers["ETag"] @account.tags.create!(title: "new-tag") get my_menu_path, headers: { "If-None-Match" => etag } assert_response :success end test "etag invalidates when users change" do get my_menu_path assert_response :success etag = response.headers["ETag"] @user.touch get my_menu_path, headers: { "If-None-Match" => etag } assert_response :success end test "etag invalidates when account changes" do get my_menu_path assert_response :success etag = response.headers["ETag"] @account.update!(name: "Renamed Account") get my_menu_path, headers: { "If-None-Match" => etag } assert_response :success end test "etag returns not modified when nothing changes" do get my_menu_path assert_response :success etag = response.headers["ETag"] get my_menu_path, headers: { "If-None-Match" => etag } assert_response :not_modified end test "show excludes cancelled accounts" do # Create another account for the same identity another_account = Account.create!(external_account_id: 9999996, name: "Cancelled Account") another_user = @user.identity.users.create!(account: another_account, name: "Kevin", role: "owner") # Cancel the other account another_account.cancel(initiated_by: another_user) get my_menu_path assert_response :success # The response should include active account but not cancelled one assert_select "a[href*='#{@account.slug}']" assert_select "a[href*='#{another_account.slug}']", count: 0 end end ================================================ FILE: test/controllers/my/passkey_challenges_controller_test.rb ================================================ require "test_helper" class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest test "returns a fresh challenge" do untenanted do post my_passkey_challenge_url assert_response :success assert_not_nil response.parsed_body["challenge"] end end test "stores challenge in cookie" do untenanted do post my_passkey_challenge_url jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash) assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME] end end test "returns a different challenge each time" do untenanted do post my_passkey_challenge_url first_challenge = response.parsed_body["challenge"] post my_passkey_challenge_url second_challenge = response.parsed_body["challenge"] assert_not_equal first_challenge, second_challenge end end end ================================================ FILE: test/controllers/my/passkeys_controller_test.rb ================================================ require "test_helper" class My::PasskeysControllerTest < ActionDispatch::IntegrationTest include WebauthnTestHelper setup do sign_in_as :kevin end test "index" do get my_passkeys_path assert_response :success end test "register a passkey" do challenge = request_webauthn_challenge assert_difference -> { identities(:kevin).passkeys.count }, 1 do post my_passkeys_path, params: build_attestation_params(challenge: challenge) end passkey = identities(:kevin).passkeys.order(created_at: :desc).first assert_redirected_to edit_my_passkey_path(passkey, created: true) assert_equal [ "internal" ], passkey.transports end end ================================================ FILE: test/controllers/my/pins_controller_test.rb ================================================ require "test_helper" class My::PinsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get my_pins_path assert_response :success assert_select "div", text: /#{users(:kevin).pins.first.card.title}/ end test "index as JSON" do expected_ids = users(:kevin).pins.ordered.pluck(:card_id) get my_pins_path(format: :json) assert_response :success assert_equal expected_ids.count, @response.parsed_body.count assert_equal expected_ids, @response.parsed_body.map { |card| card["id"] } end end ================================================ FILE: test/controllers/my/timezones_controller_test.rb ================================================ require "test_helper" class My::TimezonesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update" do time_zone = ActiveSupport::TimeZone["America/New_York"] assert_not_equal time_zone, users(:kevin).timezone patch my_timezone_path, params: { timezone_name: "America/New_York" } assert_equal time_zone, users(:kevin).reload.timezone end end ================================================ FILE: test/controllers/notifications/bulk_readings_controller_test.rb ================================================ require "test_helper" class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create marks all notifications as read" do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path end end end test "create redirects to notifications path when not from tray" do post bulk_reading_path assert_redirected_to notifications_path end test "create returns ok when from tray" do post bulk_reading_path, params: { from_tray: true } assert_response :ok end test "create as JSON" do assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do post bulk_reading_path, as: :json end end assert_response :no_content end end ================================================ FILE: test/controllers/notifications/readings_controller_test.rb ================================================ require "test_helper" class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @notification = notifications(:logo_assignment_kevin) end test "create" do assert_changes -> { @notification.reload.read? }, from: false, to: true do post notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "destroy" do @notification.read assert_changes -> { @notification.reload.read? }, from: true, to: false do delete notification_reading_path(@notification, format: :turbo_stream) assert_response :success end end test "create as JSON" do assert_changes -> { @notification.reload.read? }, from: false, to: true do post notification_reading_path(@notification), as: :json assert_response :no_content end end test "destroy as JSON" do @notification.read assert_changes -> { @notification.reload.read? }, from: true, to: false do delete notification_reading_path(@notification), as: :json assert_response :no_content end end end ================================================ FILE: test/controllers/notifications/settings_controller_test.rb ================================================ require "test_helper" class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:david) sign_in_as @user end test "show" do get notifications_settings_path assert_response :success end test "show as JSON" do get notifications_settings_path, as: :json assert_response :success assert_equal @user.settings.bundle_email_frequency, @response.parsed_body["bundle_email_frequency"] end test "update as JSON" do assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }, as: :json end assert_response :no_content end test "update email frequency" do assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } } end assert_redirected_to notifications_settings_path assert_equal "Settings updated", flash[:notice] end end ================================================ FILE: test/controllers/notifications/trays_controller_test.rb ================================================ require "test_helper" class Notifications::TraysControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show" do get tray_notifications_path assert_response :success assert_select "div", text: /Layout is broken/ end test "show as JSON" do expected_ids = users(:kevin).notifications.unread.ordered.limit(100).pluck(:id) get tray_notifications_path(format: :json) assert_response :success assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } end test "show as JSON with include_read includes read notifications" do notifications = users(:kevin).notifications expected_ids = notifications.unread.ordered.limit(100).pluck(:id) + notifications.read.ordered.limit(100).pluck(:id) get tray_notifications_path(format: :json, include_read: true) assert_response :success assert_equal expected_ids, @response.parsed_body.map { |s| s["id"] } end end ================================================ FILE: test/controllers/notifications/unsubscribes_controller_test.rb ================================================ require "test_helper" class Notifications::UnsubscribesControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:david) @access_token = @user.generate_token_for(:unsubscribe) sign_in_as @user end test "new" do get new_notifications_unsubscribe_path(access_token: @access_token) assert_response :success end test "new with bad token" do get new_notifications_unsubscribe_path(access_token: "bad") assert_redirected_to root_path end test "create" do @user.reload.settings.bundle_email_every_few_hours! assert_changes -> { @user.reload.settings.bundle_email_frequency }, to: "never" do post notifications_unsubscribe_path(access_token: @access_token) assert_redirected_to notifications_unsubscribe_path(access_token: @access_token) end end test "create with bad token" do assert_no_changes -> { @user.reload.settings.bundle_email_frequency } do post notifications_unsubscribe_path(access_token: "bad") assert_redirected_to root_path end end test "show" do get notifications_unsubscribe_path(access_token: @access_token) assert_response :success end end ================================================ FILE: test/controllers/notifications_controller_test.rb ================================================ require "test_helper" class NotificationsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index as JSON" do get notifications_path, as: :json assert_response :success assert_kind_of Array, @response.parsed_body assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id } end test "index as JSON includes notification attributes" do get notifications_path, as: :json notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id } assert_not_nil notification["created_at"] assert_not_nil notification["card"] assert_not_nil notification["creator"] assert_not_nil notification["unread_count"] assert_not_nil notification.dig("creator", "avatar_url") assert_not_nil notification.dig("card", "number") assert_not_nil notification.dig("card", "board_name") assert_not_nil notification.dig("card", "column") card = notifications(:logo_assignment_kevin).card assert_equal card.closed?, notification.dig("card", "closed") assert_equal card.postponed?, notification.dig("card", "postponed") end end ================================================ FILE: test/controllers/prompts/boards/users_controller_test.rb ================================================ require "test_helper" class Prompts::Boards::UsersControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @board = boards(:writebook) end test "index" do get prompts_board_users_path(@board) assert_response :success assert_select "lexxy-prompt-item", count: 3 end test "index excludes inactive users" do get prompts_board_users_path(@board) assert_response :success assert_select "lexxy-prompt-item[search*='David']", count: 1 users(:david).update!(active: false) get prompts_board_users_path(@board) assert_response :success assert_select "lexxy-prompt-item[search*='David']", count: 0 end end ================================================ FILE: test/controllers/prompts/cards_controller_test.rb ================================================ require "test_helper" class Prompts::CardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get prompts_cards_path assert_response :success end end ================================================ FILE: test/controllers/prompts/tags_controller_test.rb ================================================ require "test_helper" class Prompts::TagsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get prompts_tags_path assert_response :success end end ================================================ FILE: test/controllers/prompts/users_controller_test.rb ================================================ require "test_helper" class Prompts::UsersControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get prompts_users_path assert_response :success end end ================================================ FILE: test/controllers/public/boards/columns/closeds_controller_test.rb ================================================ require "test_helper" class Public::Boards::Columns::ClosedsControllerTest < ActionDispatch::IntegrationTest setup do boards(:writebook).publish end test "show" do get public_board_columns_closed_path(boards(:writebook).publication.key) assert_response :success end test "show excludes draft cards" do draft_card = cards(:buy_domain) draft_card.update!(status: :drafted) Current.set(user: users(:david)) { draft_card.close } get public_board_columns_closed_path(boards(:writebook).publication.key) assert_response :success assert_not_includes response.body, draft_card.title end end ================================================ FILE: test/controllers/public/boards/columns/not_nows_controller_test.rb ================================================ require "test_helper" class Public::Boards::Columns::NotNowsControllerTest < ActionDispatch::IntegrationTest setup do boards(:writebook).publish end test "show" do get public_board_columns_not_now_path(boards(:writebook).publication.key) assert_response :success end end ================================================ FILE: test/controllers/public/boards/columns/streams_controller_test.rb ================================================ require "test_helper" class Public::Boards::Columns::StreamsControllerTest < ActionDispatch::IntegrationTest setup do boards(:writebook).publish end test "show" do get public_board_columns_stream_path(boards(:writebook).publication.key) assert_response :success end end ================================================ FILE: test/controllers/public/boards/columns_controller_test.rb ================================================ require "test_helper" class Public::Boards::ColumnsControllerTest < ActionDispatch::IntegrationTest setup do boards(:writebook).publish end test "show" do column = columns(:writebook_in_progress) get public_board_column_path(boards(:writebook).publication.key, column) assert_response :success end end ================================================ FILE: test/controllers/public/boards_controller_test.rb ================================================ require "test_helper" class Public::BoardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin boards(:writebook).publish end test "show" do get published_board_path(boards(:writebook)) assert_response :success end test "not found if the board is not published" do key = boards(:writebook).publication.key boards(:writebook).unpublish get public_board_path(key) assert_response :not_found end test "show excludes draft cards from closed count" do draft_card = cards(:buy_domain) draft_card.update!(status: :drafted) Current.set(user: users(:david)) { draft_card.close } get published_board_path(boards(:writebook)) assert_response :success assert_select ".cards--closed .cards__expander-count", "1" end test "show works without authentication" do sign_out get published_board_path(boards(:writebook)) assert_response :success end end ================================================ FILE: test/controllers/public/cards_controller_test.rb ================================================ require "test_helper" class Public::CardsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin @board = boards(:writebook) @card = cards(:logo) @board.publish end test "show" do get public_board_card_path(@board.publication.key, @card) assert_response :success end test "not found if the board is not published" do @board.unpublish get public_board_card_path(@board.publication.key, @card) assert_response :not_found end test "not found if the card is drafted" do @card.update!(status: :drafted) get public_board_card_path(@board.publication.key, @card) assert_response :not_found end end ================================================ FILE: test/controllers/qr_codes_controller_test.rb ================================================ require "test_helper" class QrCodesControllerTest < ActionDispatch::IntegrationTest test "show" do join_code = account_join_codes(:"37s") account = accounts("37s") url = join_url(code: join_code.code, script_name: account.slug, host: "app.fizzy.do") signed_token = QrCodeLink.new(url).signed get qr_code_path(signed_token) assert_response :success assert_match %r{image/svg\+xml}, response.content_type assert_includes response.body, " { users(:kevin).search_queries.count }, +1 do post searches_queries_path, params: { q: "layout issues" } end assert_equal "layout issues", users(:kevin).search_queries.last.terms assert_response :success end end ================================================ FILE: test/controllers/searches_controller_test.rb ================================================ require "test_helper" class SearchesControllerTest < ActionDispatch::IntegrationTest include SearchTestHelper setup do @board.update!(all_access: true) @card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", status: "published", creator: @user) @comment_card = @board.cards.create!(title: "Some card", status: "published", creator: @user) @comment_card.comments.create!(body: "overflowing text issue", creator: @user) @comment2_card = @board.cards.create!(title: "Just haggis", description: "More haggis", status: "published", creator: @user) @comment2_card.comments.create!(body: "I love haggis", creator: @user) untenanted { sign_in_as @user } end test "search" do # Search query is blank get search_path(q: "", script_name: "/#{@account.external_account_id}") assert @query.nil? # Searching by card title get search_path(q: "broken", script_name: "/#{@account.external_account_id}") 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 .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 assert_match(/<\/span>haggis<\/mark>/, response.body) # Searching by card id get search_path(q: @card.id, script_name: "/#{@account.external_account_id}") assert_select "form[data-controller='auto-submit']" # Searching with non-existent card id get search_path(q: "999999", script_name: "/#{@account.external_account_id}") assert_select "form[data-controller='auto-submit']", count: 0 assert_select ".search__blank-slate", text: "No matches" end test "search as JSON" do get search_path(q: "broken", script_name: "/#{@account.external_account_id}"), as: :json assert_response :success body = @response.parsed_body assert_kind_of Array, body assert_equal 1, body.size assert_equal "Layout is broken", body.first["title"] end test "search by card ID as JSON returns array" do get search_path(q: @card.id, script_name: "/#{@account.external_account_id}"), as: :json assert_response :success body = @response.parsed_body assert_kind_of Array, body assert_equal 1, body.size assert_equal @card.id, body.first["id"] end test "search as JSON deduplicates cards with multiple search hits" do get search_path(q: "haggis", script_name: "/#{@account.external_account_id}"), as: :json assert_response :success body = @response.parsed_body assert_kind_of Array, body assert_equal 1, body.size assert_equal @comment2_card.id, body.first["id"] end test "search highlights matched terms with proper HTML marks" do @board.cards.create!(title: "Testing search highlighting", status: "published", creator: @user) get search_path(q: "highlighting", script_name: "/#{@account.external_account_id}") assert_response :success end test "search preserves highlight marks but escapes surrounding HTML" do @board.cards.create!( title: "Bold testing content", status: "published", creator: @user ) get search_path(q: "testing", script_name: "/#{@account.external_account_id}") assert_response :success # Should escape tags assert response.body.include?("<b>") # But should preserve highlight marks around "testing" assert_match(/<\/span>testing<\/mark>/, response.body) end end ================================================ FILE: test/controllers/sessions/magic_links_controller_test.rb ================================================ require "test_helper" class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "show" do untenanted do get session_magic_link_url assert_response :redirect, "Without an email address pending authentication, should redirect" assert_redirected_to new_session_path end untenanted do post session_path, params: { email_address: "test@example.com" } get session_magic_link_url assert_response :success end end test "create with sign in code" do identity = identities(:kevin) magic_link = MagicLink.create!(identity: identity) untenanted do post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } assert_response :redirect assert cookies[:session_token].present? assert_redirected_to landing_path, "Should redirect to after authentication path" assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed" end end test "create with sign up code" do identity = identities(:kevin) magic_link = MagicLink.create!(identity: identity, purpose: :sign_up) untenanted do post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } assert_response :redirect assert cookies[:session_token].present? assert_redirected_to new_signup_completion_path, "Should redirect to signup completion" assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed" end end test "create with cross-user code" do identity = identities(:kevin) other_identity = identities(:jason) magic_link = MagicLink.create!(identity: other_identity) untenanted do post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } assert_redirected_to new_session_path assert_not cookies[:session_token].present? end end test "create with invalid code" do identity = identities(:kevin) magic_link = MagicLink.create!(identity: identity) untenanted do post session_magic_link_url, params: { code: "INVALID" } end assert_response :redirect, "Invalid code should redirect" expired_link = MagicLink.create!(identity: identity) expired_link.update_column(:expires_at, 1.hour.ago) post session_magic_link_url, params: { code: expired_link.code } assert_response :redirect, "Expired magic link should redirect" assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed" end test "create via JSON for sign in" do identity = identities(:david) magic_link = identity.send_magic_link untenanted do post session_path(format: :json), params: { email_address: identity.email_address } post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :success assert @response.parsed_body["session_token"].present? assert_equal false, @response.parsed_body["requires_signup_completion"] end end test "create via JSON for sign up" do identity = identities(:david) magic_link = identity.send_magic_link(for: :sign_up) untenanted do post session_path(format: :json), params: { email_address: identity.email_address } post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :success assert @response.parsed_body["session_token"].present? assert_equal true, @response.parsed_body["requires_signup_completion"] end end test "create via JSON without pending_authentication_token" do identity = identities(:david) magic_link = identity.send_magic_link untenanted do post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] end end test "create via JSON with invalid code" do identity = identities(:david) untenanted do post session_path(format: :json), params: { email_address: identity.email_address } post session_magic_link_path(format: :json), params: { code: "INVALID" } assert_response :unauthorized assert_equal "Try another code.", @response.parsed_body["message"] end end test "create via JSON with cross-user code" do identity = identities(:david) other_identity = identities(:jason) magic_link = other_identity.send_magic_link untenanted do post session_path(format: :json), params: { email_address: identity.email_address } post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] end end test "create via JSON with expired pending_authentication_token" do identity = identities(:david) magic_link = identity.send_magic_link untenanted do travel_to 20.minutes.ago do post session_path(format: :json), params: { email_address: identity.email_address } end post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] end end end ================================================ FILE: test/controllers/sessions/menus_controller_test.rb ================================================ require "test_helper" class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest setup do @identity = identities(:kevin) end test "show with no account" do sign_in_as @identity @identity.users.delete_all untenanted do get session_menu_url end assert_response :success, "Renders an empty menu" end test "show with exactly one account" do sign_in_as @identity Current.without_account do @identity.users.delete_all account = Account.create!(external_account_id: 9999991, name: "Test Account") @identity.users.create!(account: account, name: "Kevin") end untenanted do get session_menu_url end assert_response :redirect assert_redirected_to root_url(script_name: "/9999991") end test "show with multiple accounts" do sign_in_as @identity @identity.users.delete_all account1 = Account.create!(external_account_id: 9999992, name: "37signals") account2 = Account.create!(external_account_id: 9999993, name: "Acme") @identity.users.create!(account: account1, name: "Kevin") @identity.users.create!(account: account2, name: "Kevin") untenanted do get session_menu_url end assert_response :success end test "show excludes cancelled accounts" do sign_in_as @identity @identity.users.delete_all account1 = Account.create!(external_account_id: 9999994, name: "Active Account") account2 = Account.create!(external_account_id: 9999995, name: "Cancelled Account") user1 = @identity.users.create!(account: account1, name: "Kevin", role: "owner") user2 = @identity.users.create!(account: account2, name: "Kevin", role: "owner") # Cancel one account account2.cancel(initiated_by: user2) untenanted do get session_menu_url end # Should redirect to the only active account assert_response :redirect assert_redirected_to root_url(script_name: account1.slug) end end ================================================ FILE: test/controllers/sessions/passkeys_controller_test.rb ================================================ require "test_helper" class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest include WebauthnTestHelper setup do @identity = identities(:kevin) @credential = @identity.passkeys.create!( name: "Test Passkey", credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false), public_key: webauthn_private_key.public_to_der, sign_count: 0, transports: [ "internal" ] ) end test "successful authentication" do untenanted do challenge = request_webauthn_challenge post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential) assert_response :redirect assert cookies[:session_token].present? assert_redirected_to landing_path end end test "updates sign count" do untenanted do challenge = request_webauthn_challenge post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1) assert_equal 1, @credential.reload.sign_count end end test "rejects invalid signature" do untenanted do challenge = request_webauthn_challenge params = build_assertion_params(challenge: challenge, credential: @credential) params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false) post session_passkey_url, params: params assert_redirected_to new_session_path assert_not cookies[:session_token].present? assert_equal "That passkey didn't work. Try again.", flash[:alert] end end test "rejects unknown credential" do untenanted do request_webauthn_challenge post session_passkey_url, params: { passkey: { id: "nonexistent", client_data_json: Base64.urlsafe_encode64("{}", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false), signature: Base64.urlsafe_encode64("x", padding: false) } } assert_redirected_to new_session_path assert_not cookies[:session_token].present? end end test "successful authentication via JSON" do untenanted do challenge = request_webauthn_challenge post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential) assert_response :success assert @response.parsed_body["session_token"].present? end end test "failed authentication via JSON" do untenanted do request_webauthn_challenge post session_passkey_url(format: :json), params: { passkey: { id: "nonexistent", client_data_json: Base64.urlsafe_encode64("{}", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false), signature: Base64.urlsafe_encode64("x", padding: false) } } assert_response :unauthorized assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"] end end end ================================================ FILE: test/controllers/sessions/transfers_controller_test.rb ================================================ require "test_helper" class Sessions::TransfersControllerTest < ActionDispatch::IntegrationTest test "show renders when not signed in" do untenanted do get session_transfer_path("some-token") assert_response :success end end test "update establishes a session when the code is valid" do identity = identities(:david) untenanted do put session_transfer_path(identity.transfer_id) assert_redirected_to session_menu_url(script_name: nil) assert parsed_cookies.signed[:session_token] end end end ================================================ FILE: test/controllers/sessions_controller_test.rb ================================================ require "test_helper" class SessionsControllerTest < ActionDispatch::IntegrationTest test "new" do untenanted do get new_session_path end assert_response :success end test "new redirects authenticated users" do sign_in_as :kevin untenanted do get new_session_path assert_redirected_to root_url end end test "create" do identity = identities(:kevin) untenanted do assert_difference -> { MagicLink.count }, 1 do post session_path, params: { email_address: identity.email_address } end assert_redirected_to session_magic_link_path assert_nil flash[:magic_link_code] end end test "create for a new user" do untenanted do assert_difference -> { MagicLink.count }, +1 do assert_difference -> { Identity.count }, +1 do post session_path, params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } end end assert_redirected_to session_magic_link_path assert MagicLink.last.for_sign_up? end end test "create for a new user when single tenant mode already has a tenant" do with_multi_tenant_mode(false) do untenanted do assert_no_difference -> { MagicLink.count } do assert_no_difference -> { Identity.count } do post session_path, params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } end end assert_redirected_to session_magic_link_path end end end test "create with invalid email address" do # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form # field validation that should normally prevent this from occurring, so I'm not worried about # returning proper validation errors. without_action_dispatch_exception_handling do untenanted do assert_no_difference -> { Identity.count } do post session_path, params: { email_address: "not-a-valid-email" } end assert_response :redirect assert_redirected_to new_session_path end end end test "destroy" do sign_in_as :kevin untenanted do delete session_path assert_redirected_to new_session_path assert_not cookies[:session_token].present? end end test "create via JSON" do untenanted do post session_path(format: :json), params: { email_address: identities(:david).email_address } assert_response :created end end test "create for a new user via JSON" do new_email = "new-user-#{SecureRandom.hex(6)}@example.com" untenanted do assert_difference -> { Identity.count }, 1 do assert_difference -> { MagicLink.count }, 1 do post session_path(format: :json), params: { email_address: new_email } end end assert_response :created assert @response.parsed_body["pending_authentication_token"].present? assert MagicLink.last.for_sign_up? end end test "create with invalid email address via JSON" do untenanted do assert_no_difference -> { Identity.count } do post session_path(format: :json), params: { email_address: "not-a-valid-email" } end assert_response :unprocessable_entity end end test "destroy via JSON" do sign_in_as :kevin untenanted do delete session_path(format: :json) assert_response :no_content assert_not cookies[:session_token].present? end end end ================================================ FILE: test/controllers/signup/completions_controller_test.rb ================================================ require "test_helper" class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest setup do @signup = Signup.new(email_address: "newuser@example.com", full_name: "New User") @signup.create_identity || raise("Failed to create identity") sign_in_as @signup.identity end test "new" do untenanted do get new_signup_completion_path end assert_response :success end test "create" do untenanted do post signup_completion_path, params: { signup: { full_name: @signup.full_name } } end assert_response :redirect, "Valid params should redirect" end test "shows welcome letter after signup" do untenanted do post signup_completion_path, params: { signup: { full_name: @signup.full_name } } end assert flash[:welcome_letter] end test "create with blank name" do untenanted do post signup_completion_path, params: { signup: { full_name: "" } } end assert_response :unprocessable_entity assert_select ".txt-negative" do assert_select "li", text: "Full name can't be blank" end end test "create via JSON" do untenanted do assert_difference -> { Account.count }, +1 do post signup_completion_path(format: :json), params: { signup: { full_name: @signup.full_name } } end end assert_response :created end test "create via JSON with blank name" do untenanted do assert_no_difference -> { Account.count } do post signup_completion_path(format: :json), params: { signup: { full_name: "" } } end end assert_response :unprocessable_entity assert_includes @response.parsed_body["errors"], "Full name can't be blank" end end ================================================ FILE: test/controllers/signups_controller_test.rb ================================================ require "test_helper" class SignupsControllerTest < ActionDispatch::IntegrationTest test "new" do untenanted do get new_signup_path assert_response :success end end test "new for an authenticated user" do identity = identities(:kevin) sign_in_as identity untenanted do get new_signup_path assert_redirected_to new_signup_completion_path end end test "create" do email_address = "newuser-#{SecureRandom.hex(6)}@example.com" untenanted do assert_difference -> { Identity.count }, +1 do assert_difference -> { MagicLink.count }, +1 do post signup_path, params: { signup: { email_address: email_address } } end end assert_redirected_to session_magic_link_path end end test "create with invalid email address" do without_action_dispatch_exception_handling do untenanted do assert_no_difference -> { Identity.count } do assert_no_difference -> { MagicLink.count } do post signup_path, params: { signup: { email_address: "not-a-valid-email" } } end end assert_response :unprocessable_entity end end end test "create for an authenticated user" do identity = identities(:kevin) sign_in_as identity untenanted do assert_no_difference -> { Identity.count } do assert_no_difference -> { MagicLink.count } do post signup_path, params: { signup: { email_address: identity.email_address } } end end assert_redirected_to new_signup_completion_path end end test "redirects to session#new when single_tenant and user exists" do users(:david) with_multi_tenant_mode(false) do untenanted do get new_signup_path assert_redirected_to new_session_url end end end end ================================================ FILE: test/controllers/tags_controller_test.rb ================================================ require "test_helper" class TagsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index as JSON" do tags = users(:kevin).account.tags.alphabetically get tags_path, as: :json assert_response :success assert_equal tags.count, @response.parsed_body.count assert_equal tags.pluck(:title), @response.parsed_body.pluck("title") end end ================================================ FILE: test/controllers/users/avatars_controller_test.rb ================================================ require "test_helper" class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :david end test "show system user" do get user_avatar_path(users(:system)) assert_response :redirect assert_redirected_to ActionController::Base.helpers.image_path("system_user.png") end test "show own initials without caching" do get user_avatar_path(users(:david)) assert_match "image/svg+xml", @response.content_type assert @response.cache_control[:private] assert_equal "0", @response.cache_control[:max_age] end test "show other initials with caching" do get user_avatar_path(users(:kevin)) assert_match "image/svg+xml", @response.content_type assert @response.cache_control[:private] assert_equal 30.minutes.to_s, @response.cache_control[:max_age] end test "show own image redirects to the blob url" do users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg") assert users(:david).avatar.attached? get user_avatar_path(users(:david)) assert_redirected_to rails_blob_url(users(:david).avatar_thumbnail, disposition: "inline") end test "show other image redirects to the blob url" do users(:kevin).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg") assert users(:kevin).avatar.attached? get user_avatar_path(users(:kevin)) assert_redirected_to rails_blob_url(users(:kevin).avatar_thumbnail, disposition: "inline") end test "delete self" do delete user_avatar_path(users(:david)) assert_redirected_to users(:david) end test "delete self as JSON" do delete user_avatar_path(users(:david)), as: :json assert_response :no_content end test "unable to delete other" do delete user_avatar_path(users(:kevin)) assert_response :forbidden end end ================================================ FILE: test/controllers/users/data_exports_controller_test.rb ================================================ require "test_helper" class Users::DataExportsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :david @user = users(:david) end test "create creates an export record and enqueues job" do assert_difference -> { User::DataExport.count }, 1 do assert_enqueued_with(job: DataExportJob) do post user_data_exports_path(@user) end end assert_redirected_to @user assert_equal "Export started. You'll receive an email when it's ready.", flash[:notice] end test "create associates export with user and account" do post user_data_exports_path(@user) export = User::DataExport.last assert_equal @user, export.user assert_equal Current.account, export.account assert export.pending? end test "create rejects request when current export limit is reached" do Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do @user.data_exports.create!(account: Current.account) end assert_no_difference -> { User::DataExport.count } do post user_data_exports_path(@user) end assert_response :too_many_requests end test "create allows request when exports are older than one day" do Users::DataExportsController::CURRENT_EXPORT_LIMIT.times do @user.data_exports.create!(account: Current.account, created_at: 2.days.ago) end assert_difference -> { User::DataExport.count }, 1 do post user_data_exports_path(@user) end assert_redirected_to @user end test "show displays completed export with download link" do export = @user.data_exports.create!(account: Current.account) export.build get user_data_export_path(@user, export) assert_response :success assert_select "a#download-link" end test "show displays a warning if the export is missing" do get user_data_export_path(@user, "not-really-an-export") assert_response :success assert_select "h2", "Download Expired" end test "create is forbidden for other users" do other_user = users(:kevin) post user_data_exports_path(other_user) assert_response :forbidden end test "show is forbidden for other users" do other_user = users(:kevin) export = other_user.data_exports.create!(account: Current.account) export.build get user_data_export_path(other_user, export) assert_response :forbidden end end ================================================ FILE: test/controllers/users/email_addresses/confirmations_controller_test.rb ================================================ require "test_helper" class Users::EmailAddresses::ConfirmationsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:david) @old_email = @user.identity.email_address @new_email = "newemail@example.com" @token = @user.send(:generate_email_address_change_token, to: @new_email) end test "show" do get user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug) assert_response :success end test "create" do post user_email_address_confirmation_path(user_id: @user.id, email_address_token: @token, script_name: @user.account.slug) assert_equal @new_email, @user.reload.identity.email_address assert_redirected_to edit_user_url(script_name: @user.account.slug, id: @user) end test "create with invalid token" do post user_email_address_confirmation_path(user_id: @user.id, email_address_token: "invalid", script_name: @user.account.slug) assert_equal @user.identity.email_address, @old_email assert_response :unprocessable_entity assert_match /Link expired/, response.body end end ================================================ FILE: test/controllers/users/email_addresses_controller_test.rb ================================================ require "test_helper" class Users::EmailAddressesControllerTest < ActionDispatch::IntegrationTest include ActionMailer::TestHelper setup do sign_in_as :david @user = users(:david) end test "new" do get new_user_email_address_path(@user, script_name: @user.account.slug) assert_response :success end test "create" do assert_emails 1 do post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" } end assert_response :success end test "create with existing email in same account" do existing_user = users(:kevin) existing_email = existing_user.identity.email_address post user_email_addresses_path(@user, script_name: @user.account.slug), params: { email_address: existing_email } assert_redirected_to new_user_email_address_path(@user) assert_equal "You already have a user in this account with that email address", flash[:alert] end test "create for other user" do other_user = users(:kevin) assert_no_emails do post user_email_addresses_path(other_user, script_name: @user.account.slug), params: { email_address: "newemail@example.com" } end assert_response :not_found end end ================================================ FILE: test/controllers/users/events_controller_test.rb ================================================ require "test_helper" class Users::EventsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "show self" do get user_events_path(users(:kevin)) assert_in_body "What have you been up to?" end test "show other" do get user_events_path(users(:david)) assert_in_body "What has David been up to?" end end ================================================ FILE: test/controllers/users/joins_controller_test.rb ================================================ require "test_helper" class Users::JoinsControllerTest < ActionDispatch::IntegrationTest test "new" do sign_in_as :david get new_users_join_path assert_response :ok end test "create" do user = users(:david) sign_in_as user assert_no_difference -> { User.count } do post users_joins_path, params: { user: { name: "David Updated" } } assert_redirected_to landing_path end assert_equal "David Updated", user.reload.name end end ================================================ FILE: test/controllers/users/push_subscriptions_controller_test.rb ================================================ require "test_helper" class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest PUBLIC_TEST_IP = "142.250.185.206" setup do sign_in_as :david stub_dns_resolution(PUBLIC_TEST_IP) end test "create new push subscription" do subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" } assert_response :no_content assert_equal subscription_params, users(:david).push_subscriptions.last.attributes.slice("endpoint", "p256dh_key", "auth_key") assert_equal "Mozilla/5.0", users(:david).push_subscriptions.last.user_agent end test "create as JSON" do subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }, as: :json assert_response :created end test "create as JSON for duplicate subscription" do subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } users(:david).push_subscriptions.create!(subscription_params) assert_no_difference -> { Push::Subscription.count } do post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params }, as: :json end assert_response :created end test "destroy as JSON" do subscription = users(:david).push_subscriptions.create!( endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "123", auth_key: "456" ) assert_difference -> { Push::Subscription.count }, -1 do delete user_push_subscription_path(users(:david), subscription), as: :json end assert_response :no_content end test "destroy a push subscription" do subscription = users(:david).push_subscriptions.create!( endpoint: "https://fcm.googleapis.com/fcm/send/abc123", p256dh_key: "123", auth_key: "456" ) assert_difference -> { Push::Subscription.count }, -1 do delete user_push_subscription_path(users(:david), subscription) assert_redirected_to user_push_subscriptions_path(users(:david)) end end test "rejects subscription with non-permitted endpoint" do subscription_params = { "endpoint" => "https://attacker.example.com/steal", "p256dh_key" => "123", "auth_key" => "456" } assert_no_difference -> { Push::Subscription.count } do post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params } end assert_response :unprocessable_entity end test "rejects subscription with endpoint resolving to private IP" do stub_dns_resolution("192.168.1.1") subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" } assert_no_difference -> { Push::Subscription.count } do post user_push_subscriptions_path(users(:david)), params: { push_subscription: subscription_params } end assert_response :unprocessable_entity end private def stub_dns_resolution(*ips) dns_mock = mock("dns") dns_mock.stubs(:each_address).multiple_yields(*ips) Resolv::DNS.stubs(:open).yields(dns_mock) end end ================================================ FILE: test/controllers/users/roles_controller_test.rb ================================================ require "test_helper" class Users::RolesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "update" do assert_not users(:david).admin? put user_role_path(users(:david)), params: { user: { role: "admin" } } assert_redirected_to account_settings_path assert users(:david).reload.admin? end test "update as JSON" do put user_role_path(users(:david)), params: { user: { role: "admin" } }, as: :json assert_response :no_content assert users(:david).reload.admin? end test "can't promote to special roles" do assert_no_changes -> { users(:david).reload.role } do put user_role_path(users(:david)), params: { user: { role: "system" } } end assert_no_changes -> { users(:david).reload.role } do put user_role_path(users(:david)), params: { user: { role: "owner" } } end end test "admin cannot demote the owner" do assert users(:jason).owner? assert_no_changes -> { users(:jason).reload.role } do put user_role_path(users(:jason)), params: { user: { role: "admin" } } end assert_response :forbidden end test "admin cannot change owner role to member" do assert users(:jason).owner? assert_no_changes -> { users(:jason).reload.role } do put user_role_path(users(:jason)), params: { user: { role: "member" } } end assert_response :forbidden end end ================================================ FILE: test/controllers/users/verifications_controller_test.rb ================================================ require "test_helper" class Users::VerificationsControllerTest < ActionDispatch::IntegrationTest test "new renders the auto-submit form" do sign_in_as :david get new_users_verification_path assert_response :ok end test "create verifies the user and redirects to join" do sign_in_as :david user = users(:david) user.update_column(:verified_at, nil) assert_not user.verified? post users_verifications_path assert_redirected_to new_users_join_path assert user.reload.verified? end end ================================================ FILE: test/controllers/users_controller_test.rb ================================================ require "test_helper" class UsersControllerTest < ActionDispatch::IntegrationTest test "show" do sign_in_as :kevin get user_path(users(:david)) assert_in_body users(:david).name end test "update oneself" do sign_in_as :kevin get edit_user_path(users(:kevin)) assert_response :ok put user_path(users(:kevin)), params: { user: { name: "New Kevin" } } assert_redirected_to user_path(users(:kevin)) assert_equal "New Kevin", users(:kevin).reload.name end test "update other as admin" do sign_in_as :kevin get edit_user_path(users(:david)) assert_response :ok put user_path(users(:david)), params: { user: { name: "New David" } } assert_redirected_to user_path(users(:david)) assert_equal "New David", users(:david).reload.name end test "destroy" do sign_in_as :kevin assert_difference -> { User.active.count }, -1 do delete user_path(users(:david)) end assert_redirected_to account_settings_path assert_nil User.active.find_by(id: users(:david).id) end test "admin cannot deactivate the owner" do sign_in_as :kevin assert users(:jason).owner? assert users(:jason).active assert_no_difference -> { User.active.count } do delete user_path(users(:jason)) end assert_response :forbidden assert users(:jason).reload.active end test "non-admins cannot perform actions" do sign_in_as :jz put user_path(users(:david)), params: { user: { role: "admin" } } assert_response :forbidden delete user_path(users(:david)) assert_response :forbidden end test "update with invalid avatar content type shows validation error" do sign_in_as :kevin svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") put user_path(users(:kevin)), params: { user: { avatar: svg_file } } assert_response :unprocessable_entity assert_select "form[action='#{user_path(users(:kevin))}']" assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/ end test "update with oversized avatar shows validation error" do sign_in_as :kevin png_file = fixture_file_upload("avatar.png", "image/png") ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 }) put user_path(users(:kevin)), params: { user: { avatar: png_file } } assert_response :unprocessable_entity assert_select ".txt-negative", text: /width must be less than 4096px/ end test "update with valid avatar" do sign_in_as :kevin png_file = fixture_file_upload("avatar.png", "image/png") put user_path(users(:kevin)), params: { user: { avatar: png_file } } assert_redirected_to user_path(users(:kevin)) assert users(:kevin).reload.avatar.attached? assert_equal "image/png", users(:kevin).avatar.content_type end test "index as JSON" do sign_in_as :kevin get users_path, as: :json assert_response :success assert_equal users(:kevin).account.users.active.count, @response.parsed_body.count end test "show as JSON" do sign_in_as :kevin get user_path(users(:david)), as: :json assert_response :success assert_equal users(:david).name, @response.parsed_body["name"] end test "update as JSON" do sign_in_as :kevin put user_path(users(:david)), params: { user: { name: "New David" } }, as: :json assert_response :no_content assert_equal "New David", users(:david).reload.name end test "update as JSON with invalid avatar returns errors" do sign_in_as :kevin svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") put user_path(users(:kevin), format: :json), params: { user: { avatar: svg_file } } assert_response :unprocessable_entity assert @response.parsed_body["avatar"].present? end test "destroy as JSON" do sign_in_as :kevin assert_difference -> { User.active.count }, -1 do delete user_path(users(:david)), as: :json end assert_response :no_content end test "index avoids N+1 queries on identity" do sign_in_as :kevin assert_queries_match(/FROM [`"]identities[`"].* IN \(/, count: 1) do get users_path, as: :json assert_response :success end json = @response.parsed_body assert json.first["email_address"].present? end end ================================================ FILE: test/controllers/webhooks/activations_controller_test.rb ================================================ require "test_helper" class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "create" do webhook = webhooks(:inactive) assert_not webhook.active? assert_changes -> { webhook.reload.active? }, from: false, to: true do post board_webhook_activation_path(webhook.board, webhook) end assert_redirected_to board_webhook_path(webhook.board, webhook) end test "cannot activate webhook on board without access" do logout_and_sign_in_as :jason webhook = webhooks(:inactive) # on private board, jason has no access post board_webhook_activation_path(webhook.board, webhook) assert_response :not_found end test "non-admin cannot activate webhook" do logout_and_sign_in_as :jz # member with writebook access, but not admin webhook = webhooks(:active) # on writebook board post board_webhook_activation_path(webhook.board, webhook) assert_response :forbidden end test "create as JSON" do webhook = webhooks(:inactive) assert_not webhook.active? assert_changes -> { webhook.reload.active? }, from: false, to: true do post board_webhook_activation_path(webhook.board, webhook), as: :json end assert_response :created assert_equal webhook.id, @response.parsed_body["id"] assert_equal true, @response.parsed_body["active"] end test "cannot activate webhook on board without access as JSON" do logout_and_sign_in_as :jason webhook = webhooks(:inactive) post board_webhook_activation_path(webhook.board, webhook), as: :json assert_response :not_found end test "non-admin cannot activate webhook as JSON" do logout_and_sign_in_as :jz webhook = webhooks(:active) post board_webhook_activation_path(webhook.board, webhook), as: :json assert_response :forbidden end end ================================================ FILE: test/controllers/webhooks_controller_test.rb ================================================ require "test_helper" class WebhooksControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin end test "index" do get board_webhooks_path(boards(:writebook)) assert_response :success end test "show" do webhook = webhooks(:active) get board_webhook_path(webhook.board, webhook) assert_response :success webhook = webhooks(:inactive) get board_webhook_path(webhook.board, webhook) assert_response :success end test "new" do get new_board_webhook_path(boards(:writebook)) assert_response :success assert_select "form" end test "create with valid params" do board = boards(:writebook) assert_difference "Webhook.count", 1 do post board_webhooks_path(board), params: { webhook: { name: "Test Webhook", url: "https://example.com/webhook", subscribed_actions: [ "", "card_published", "card_closed" ] } } end webhook = Webhook.last assert_redirected_to board_webhook_path(webhook.board, webhook) assert_equal board, webhook.board assert_equal "Test Webhook", webhook.name assert_equal "https://example.com/webhook", webhook.url assert_equal [ "card_published", "card_closed" ], webhook.subscribed_actions end test "create with invalid params" do board = boards(:writebook) assert_no_difference "Webhook.count" do post board_webhooks_path(board), params: { webhook: { name: "", url: "invalid-url" } } end assert_response :unprocessable_entity end test "edit" do webhook = webhooks(:active) get edit_board_webhook_path(webhook.board, webhook) assert_response :success assert_select "form" webhook = webhooks(:inactive) get edit_board_webhook_path(webhook.board, webhook) assert_response :success assert_select "form" end test "update with valid params" do webhook = webhooks(:active) patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Updated Webhook", subscribed_actions: [ "card_published" ] } } webhook.reload assert_redirected_to board_webhook_path(webhook.board, webhook) assert_equal "Updated Webhook", webhook.name assert_equal [ "card_published" ], webhook.subscribed_actions end test "update with invalid params" do webhook = webhooks(:active) patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "" } } assert_response :unprocessable_entity assert_no_changes -> { webhook.reload.url } do patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Updated Webhook", url: "https://different.com/webhook" } } end assert_redirected_to board_webhook_path(webhook.board, webhook) end test "destroy" do webhook = webhooks(:active) assert_difference "Webhook.count", -1 do delete board_webhook_path(webhook.board, webhook) end assert_redirected_to board_webhooks_path(webhook.board) end test "cannot access webhooks on board without access" do logout_and_sign_in_as :jason webhook = webhooks(:inactive) # on private board, jason has no access get board_webhooks_path(webhook.board) assert_response :not_found end test "index as JSON" do board = boards(:writebook) get board_webhooks_path(board), as: :json assert_response :success assert_kind_of Array, @response.parsed_body assert_equal board.webhooks.count, @response.parsed_body.count assert_equal webhooks(:active).id, @response.parsed_body.first["id"] end test "show as JSON" do webhook = webhooks(:active) get board_webhook_path(webhook.board, webhook), as: :json assert_response :success assert_equal webhook.id, @response.parsed_body["id"] assert_equal webhook.name, @response.parsed_body["name"] assert_equal webhook.url, @response.parsed_body["payload_url"] assert_equal webhook.active?, @response.parsed_body["active"] assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] assert_equal webhook.subscribed_actions, @response.parsed_body["subscribed_actions"] assert_equal webhook.board.id, @response.parsed_body.dig("board", "id") end test "create as JSON" do board = boards(:writebook) assert_difference "Webhook.count", 1 do post board_webhooks_path(board), params: { webhook: { name: "Test Webhook", url: "https://example.com/webhook", subscribed_actions: [ "", "card_published", "card_closed" ] } }, as: :json end webhook = Webhook.last assert_response :created assert_equal board_webhook_url(board, webhook, format: :json), @response.headers["Location"] assert_equal webhook.id, @response.parsed_body["id"] assert_equal "https://example.com/webhook", @response.parsed_body["payload_url"] assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] end test "create with invalid params as JSON" do board = boards(:writebook) assert_no_difference "Webhook.count" do post board_webhooks_path(board), params: { webhook: { name: "", url: "invalid-url" } }, as: :json end assert_response :unprocessable_entity assert @response.parsed_body["name"].present? assert @response.parsed_body["url"].present? end test "update as JSON" do webhook = webhooks(:active) patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Updated Webhook", subscribed_actions: [ "card_published" ] } }, as: :json webhook.reload assert_response :success assert_equal "Updated Webhook", webhook.name assert_equal [ "card_published" ], webhook.subscribed_actions assert_equal "Updated Webhook", @response.parsed_body["name"] assert_equal [ "card_published" ], @response.parsed_body["subscribed_actions"] end test "update with invalid params as JSON" do webhook = webhooks(:active) patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "" } }, as: :json assert_response :unprocessable_entity assert @response.parsed_body["name"].present? end test "update does not change url as JSON" do webhook = webhooks(:active) assert_no_changes -> { webhook.reload.url } do patch board_webhook_path(webhook.board, webhook), params: { webhook: { name: "Updated Webhook", url: "https://different.com/webhook" } }, as: :json end assert_response :success assert_equal webhook.reload.url, @response.parsed_body["payload_url"] end test "destroy as JSON" do webhook = webhooks(:active) assert_difference "Webhook.count", -1 do delete board_webhook_path(webhook.board, webhook), as: :json end assert_response :no_content end test "non-admin cannot access webhook endpoints as JSON" do logout_and_sign_in_as :jz get board_webhooks_path(boards(:writebook)), as: :json assert_response :forbidden end test "cannot access webhooks on board without access as JSON" do logout_and_sign_in_as :jason get board_webhooks_path(boards(:private)), as: :json assert_response :not_found end end ================================================ FILE: test/fixtures/accesses.yml ================================================ writebook_david: id: <%= ActiveRecord::FixtureSet.identify("writebook_david", :uuid) %> account: 37s_uuid board: writebook_uuid user: david_uuid writebook_jz: id: <%= ActiveRecord::FixtureSet.identify("writebook_jz", :uuid) %> account: 37s_uuid board: writebook_uuid user: jz_uuid writebook_kevin: id: <%= ActiveRecord::FixtureSet.identify("writebook_kevin", :uuid) %> account: 37s_uuid board: writebook_uuid user: kevin_uuid private_kevin: id: <%= ActiveRecord::FixtureSet.identify("private_kevin", :uuid) %> account: 37s_uuid board: private_uuid user: kevin_uuid miltons_wish_list_mike: id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list_mike", :uuid) %> account: initech_uuid board: miltons_wish_list_uuid user: mike_uuid ================================================ FILE: test/fixtures/account/join_codes.yml ================================================ 37s: id: <%= ActiveRecord::FixtureSet.identify("37s_join_code", :uuid) %> code: 37S0-5678-9XYZ usage_count: 0 usage_limit: 10 account: 37s_uuid initech: id: <%= ActiveRecord::FixtureSet.identify("initech_join_code", :uuid) %> code: INIT-5678-9XYZ usage_count: 10 usage_limit: 10 account: initech_uuid ================================================ FILE: test/fixtures/accounts.yml ================================================ 37s: id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %> name: 37signals external_account_id: <%= ActiveRecord::FixtureSet.identify("37signals") %> cards_count: 5 initech: id: <%= ActiveRecord::FixtureSet.identify("initech", :uuid) %> name: Initech LLC external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %> cards_count: 0 acme: id: <%= ActiveRecord::FixtureSet.identify("acme", :uuid) %> name: ACME external_account_id: <%= ActiveRecord::FixtureSet.identify("acme") %> cards_count: 0 ================================================ FILE: test/fixtures/action_text/rich_texts.yml ================================================ logo_agreement_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz_rich_text", :uuid) %> account: 37s_uuid record: logo_agreement_jz_uuid (Comment) name: body body: I agree. logo_agreement_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_kevin_rich_text", :uuid) %> account: 37s_uuid record: logo_agreement_kevin_uuid (Comment) name: body body: Same, let's do it. layout_overflowing_david: id: <%= ActiveRecord::FixtureSet.identify("layout_overflowing_david_rich_text", :uuid) %> account: 37s_uuid record: layout_overflowing_david_uuid (Comment) name: body body: The text is overflowing the container. ================================================ FILE: test/fixtures/assignees_filters.yml ================================================ jz_assignments_jz: assignee_id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %> filter_id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> ================================================ FILE: test/fixtures/assignments.yml ================================================ logo_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_jz", :uuid) %> account: 37s_uuid assigner: david_uuid assignee: jz_uuid card: logo_uuid created_at: <%= 1.week.ago %> logo_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_kevin", :uuid) %> account: 37s_uuid assigner: david_uuid assignee: kevin_uuid card: logo_uuid created_at: <%= 1.day.ago %> layout_jz: id: <%= ActiveRecord::FixtureSet.identify("layout_jz", :uuid) %> account: 37s_uuid assigner: david_uuid assignee: jz_uuid card: layout_uuid ================================================ FILE: test/fixtures/boards.yml ================================================ writebook: id: <%= ActiveRecord::FixtureSet.identify("writebook", :uuid) %> name: Writebook creator: david_uuid all_access: true account: 37s_uuid private: id: <%= ActiveRecord::FixtureSet.identify("private", :uuid) %> name: Private board creator: kevin_uuid all_access: false account: 37s_uuid miltons_wish_list: id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list", :uuid) %> name: Milton's Wish List creator: mike_uuid all_access: true account: initech_uuid ================================================ FILE: test/fixtures/card/goldnesses.yml ================================================ logo: id: <%= ActiveRecord::FixtureSet.identify("logo_goldness", :uuid) %> account: 37s_uuid card: logo_uuid ================================================ FILE: test/fixtures/cards.yml ================================================ logo: id: <%= ActiveRecord::FixtureSet.identify("logo", :uuid) %> number: 1 board: writebook_uuid creator: david_uuid column: writebook_triage_uuid title: The logo isn't big enough due_on: <%= 3.days.from_now %> created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: 37s_uuid layout: id: <%= ActiveRecord::FixtureSet.identify("layout", :uuid) %> number: 2 board: writebook_uuid creator: david_uuid column: writebook_triage_uuid title: Layout is broken created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: 37s_uuid text: id: <%= ActiveRecord::FixtureSet.identify("text", :uuid) %> number: 3 board: writebook_uuid creator: kevin_uuid column: writebook_in_progress_uuid title: The text is too small created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: 37s_uuid shipping: id: <%= ActiveRecord::FixtureSet.identify("shipping", :uuid) %> number: 4 board: writebook_uuid creator: kevin_uuid column: writebook_triage_uuid title: We need to ship the app created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: 37s_uuid buy_domain: id: <%= ActiveRecord::FixtureSet.identify("buy_domain", :uuid) %> number: 5 board: writebook_uuid creator: david_uuid title: Buy domain created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: 37s_uuid radio: id: <%= ActiveRecord::FixtureSet.identify("radio", :uuid) %> number: 1 board: miltons_wish_list_uuid creator: mike_uuid title: I want to play my radio at a reasonable volume created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: initech_uuid paycheck: id: <%= ActiveRecord::FixtureSet.identify("paycheck", :uuid) %> number: 2 board: miltons_wish_list_uuid creator: mike_uuid title: I haven't received my paycheck created_at: <%= 1.week.ago %> status: published last_active_at: <%= 1.week.ago %> account: initech_uuid unfinished_thoughts: id: <%= ActiveRecord::FixtureSet.identify("unfinished_thoughts", :uuid) %> number: 3 board: miltons_wish_list_uuid creator: mike_uuid title: Some unfinished thoughts created_at: <%= 1.week.ago %> status: drafted last_active_at: <%= 1.week.ago %> account: initech_uuid ================================================ FILE: test/fixtures/closures.yml ================================================ shipping: id: <%= ActiveRecord::FixtureSet.identify("shipping_closure", :uuid) %> account: 37s_uuid card: shipping_uuid user: kevin_uuid ================================================ FILE: test/fixtures/columns.yml ================================================ # Columns for writebook board (which has qa workflow) writebook_triage: id: <%= ActiveRecord::FixtureSet.identify("writebook_triage", :uuid) %> name: Triage color: "var(--color-card-4)" board: writebook_uuid position: 0 account: 37s_uuid writebook_in_progress: id: <%= ActiveRecord::FixtureSet.identify("writebook_in_progress", :uuid) %> name: In progress color: "var(--color-card-2)" board: writebook_uuid position: 1 account: 37s_uuid writebook_on_hold: id: <%= ActiveRecord::FixtureSet.identify("writebook_on_hold", :uuid) %> name: On Hold color: "var(--color-card-4)" board: writebook_uuid position: 2 account: 37s_uuid writebook_review: id: <%= ActiveRecord::FixtureSet.identify("writebook_review", :uuid) %> name: Review color: "var(--color-card-3)" board: writebook_uuid position: 3 account: 37s_uuid ================================================ FILE: test/fixtures/comments.yml ================================================ logo_1: id: <%= ActiveRecord::FixtureSet.identify("logo_1", :uuid) %> card: logo_uuid creator: system_uuid created_at: <%= 1.week.ago %> account: 37s_uuid logo_agreement_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_jz", :uuid) %> card: logo_uuid creator: jz_uuid created_at: <%= 2.days.ago %> account: 37s_uuid logo_3: id: <%= ActiveRecord::FixtureSet.identify("logo_3", :uuid) %> card: logo_uuid creator: system_uuid created_at: <%= 1.day.ago %> account: 37s_uuid logo_agreement_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_agreement_kevin", :uuid) %> card: logo_uuid creator: kevin_uuid created_at: <%= 2.hours.ago %> account: 37s_uuid logo_5: id: <%= ActiveRecord::FixtureSet.identify("logo_5", :uuid) %> card: logo_uuid creator: system_uuid created_at: <%= 1.hour.ago %> account: 37s_uuid layout_1: id: <%= ActiveRecord::FixtureSet.identify("layout_1", :uuid) %> card: layout_uuid creator: system_uuid account: 37s_uuid layout_overflowing_david: id: <%= ActiveRecord::FixtureSet.identify("layout_overflowing_david", :uuid) %> card: layout_uuid creator: david_uuid account: 37s_uuid text_1: id: <%= ActiveRecord::FixtureSet.identify("text_1", :uuid) %> card: text_uuid creator: system_uuid account: 37s_uuid shipping_1: id: <%= ActiveRecord::FixtureSet.identify("shipping_1", :uuid) %> card: shipping_uuid creator: system_uuid account: 37s_uuid ================================================ FILE: test/fixtures/entropies.yml ================================================ 37s_account: id: <%= ActiveRecord::FixtureSet.identify("37s_account", :uuid) %> account: 37s_uuid container: 37s_uuid (Account) auto_postpone_period: <%= 30.days.to_i %> writebook_board: id: <%= ActiveRecord::FixtureSet.identify("writebook_board", :uuid) %> account: 37s_uuid container: writebook_uuid (Board) auto_postpone_period: <%= 90.days.to_i %> private_board: id: <%= ActiveRecord::FixtureSet.identify("private_board", :uuid) %> account: 37s_uuid container: private_uuid (Board) auto_postpone_period: <%= 30.days.to_i %> initech_account: id: <%= ActiveRecord::FixtureSet.identify("initech_account", :uuid) %> account: initech_uuid container: initech_uuid (Account) auto_postpone_period: <%= 30.days.to_i %> miltons_wish_list_board: id: <%= ActiveRecord::FixtureSet.identify("miltons_wish_list_board", :uuid) %> account: initech_uuid container: miltons_wish_list_uuid (Board) auto_postpone_period: <%= 90.days.to_i %> ================================================ FILE: test/fixtures/events.yml ================================================ logo_published: id: <%= ActiveRecord::FixtureSet.identify("logo_published", :uuid) %> creator: david_uuid board: writebook_uuid eventable: logo_uuid (Card) action: card_published created_at: <%= 1.week.ago %> account: 37s_uuid logo_assignment_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_jz", :uuid) %> creator: david_uuid board: writebook_uuid eventable: logo_uuid (Card) action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }.to_json %> created_at: <%= 1.week.ago + 1.hour %> account: 37s_uuid logo_assignment_david: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_david", :uuid) %> creator: david_uuid board: writebook_uuid eventable: logo_uuid (Card) action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("david", :uuid) ] }.to_json %> created_at: <%= 1.week.ago + 1.hour %> account: 37s_uuid logo_assignment_km: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_km", :uuid) %> creator: david_uuid board: writebook_uuid eventable: logo_uuid (Card) action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("kevin", :uuid) ] }.to_json %> created_at: <%= 1.day.ago %> account: 37s_uuid layout_published: id: <%= ActiveRecord::FixtureSet.identify("layout_published", :uuid) %> creator: david_uuid board: writebook_uuid eventable: layout_uuid (Card) action: card_published created_at: <%= 1.week.ago %> account: 37s_uuid layout_commented: id: <%= ActiveRecord::FixtureSet.identify("layout_commented", :uuid) %> creator: david_uuid board: writebook_uuid eventable: layout_overflowing_david_uuid (Comment) action: comment_created created_at: <%= 1.week.ago %> account: 37s_uuid layout_assignment_jz: id: <%= ActiveRecord::FixtureSet.identify("layout_assignment_jz", :uuid) %> creator: david_uuid board: writebook_uuid eventable: layout_uuid (Card) action: card_assigned particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }.to_json %> created_at: <%= 1.hour.ago %> account: 37s_uuid text_published: id: <%= ActiveRecord::FixtureSet.identify("text_published", :uuid) %> creator: kevin_uuid board: writebook_uuid eventable: text_uuid (Card) action: card_published created_at: <%= 1.week.ago %> account: 37s_uuid shipping_published: id: <%= ActiveRecord::FixtureSet.identify("shipping_published", :uuid) %> creator: kevin_uuid board: writebook_uuid eventable: shipping_uuid (Card) action: card_published created_at: <%= 1.week.ago %> account: 37s_uuid shipping_closed: id: <%= ActiveRecord::FixtureSet.identify("shipping_closed", :uuid) %> creator: kevin_uuid board: writebook_uuid eventable: shipping_uuid (Card) action: card_closed created_at: <%= 2.days.ago %> account: 37s_uuid ================================================ FILE: test/fixtures/exports.yml ================================================ pending_account_export: id: <%= ActiveRecord::FixtureSet.identify("pending_account_export", :uuid) %> account: 37s_uuid user: david_uuid type: Account::Export status: pending completed_account_export: id: <%= ActiveRecord::FixtureSet.identify("completed_account_export", :uuid) %> account: 37s_uuid user: david_uuid type: Account::Export status: completed completed_at: <%= 1.hour.ago.to_fs(:db) %> pending_user_data_export: id: <%= ActiveRecord::FixtureSet.identify("pending_user_data_export", :uuid) %> account: 37s_uuid user: david_uuid type: User::DataExport status: pending completed_user_data_export: id: <%= ActiveRecord::FixtureSet.identify("completed_user_data_export", :uuid) %> account: 37s_uuid user: david_uuid type: User::DataExport status: completed completed_at: <%= 1.hour.ago.to_fs(:db) %> ================================================ FILE: test/fixtures/filters.yml ================================================ jz_assignments: id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> creator: david_uuid fields: <%= { indexed_by: :all, sorted_by: :newest }.to_json %> params_digest: <%= Filter.digest_params({ indexed_by: :all, sorted_by: :newest, tag_ids: [ ActiveRecord::FixtureSet.identify("mobile", :uuid) ], assignee_ids: [ ActiveRecord::FixtureSet.identify("jz", :uuid) ] }) %> account: 37s_uuid ================================================ FILE: test/fixtures/filters_tags.yml ================================================ jz_assignments_mobile: filter_id: <%= ActiveRecord::FixtureSet.identify("jz_assignments", :uuid) %> tag_id: <%= ActiveRecord::FixtureSet.identify("mobile", :uuid) %> ================================================ FILE: test/fixtures/identities.yml ================================================ david: email_address: david@37signals.com staff: true jz: email_address: jz@37signals.com jason: email_address: jason@37signals.com staff: true kevin: email_address: kevin@37signals.com staff: true mike: email_address: mike@37signals.com ================================================ FILE: test/fixtures/identity/access_tokens.yml ================================================ jasons_api_token: identity: jason token: 018cf1425682700098f24f0799e3fe20 description: My Superscript permission: read davids_api_token: identity: david token: x18cf1425682700098f24f0799e3fe20 description: My Superscript permission: write ================================================ FILE: test/fixtures/mentions.yml ================================================ logo_card_david_mention_by_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz", :uuid) %> account: 37s_uuid source: logo_uuid (Card) mentioner: jz_uuid mentionee: david_uuid logo_comment_david_mention_by_jz: id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz", :uuid) %> account: 37s_uuid source: logo_agreement_jz_uuid (Comment) mentioner: jz_uuid mentionee: david_uuid ================================================ FILE: test/fixtures/notifications.yml ================================================ logo_assignment_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %> user: kevin_uuid source: logo_assignment_km_uuid (Event) card: logo_uuid unread_count: 2 created_at: <%= 1.week.ago %> updated_at: <%= 1.week.ago + 1.second %> creator: david_uuid account: 37s_uuid layout_commented_kevin: id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %> user: kevin_uuid source: layout_commented_uuid (Event) card: layout_uuid unread_count: 1 created_at: <%= 1.week.ago %> updated_at: <%= 1.week.ago + 2.seconds %> creator: david_uuid account: 37s_uuid logo_mentioned_david: id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %> user: david_uuid source: logo_comment_david_mention_by_jz_uuid (Mention) card: logo_uuid unread_count: 2 created_at: <%= 1.week.ago %> updated_at: <%= 1.week.ago + 3.seconds %> creator: david_uuid account: 37s_uuid ================================================ FILE: test/fixtures/pins.yml ================================================ logo_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_kevin_pin", :uuid) %> account: 37s_uuid card: logo_uuid user: kevin_uuid shipping_kevin: id: <%= ActiveRecord::FixtureSet.identify("shipping_kevin_pin", :uuid) %> account: 37s_uuid card: shipping_uuid user: kevin_uuid ================================================ FILE: test/fixtures/reactions.yml ================================================ kevin: id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %> account: 37s_uuid content: "👍" reactable: logo_agreement_jz_uuid (Comment) reacter: kevin_uuid david: id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %> account: 37s_uuid content: "👍" reactable: logo_agreement_jz_uuid (Comment) reacter: david_uuid logo_card_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_card_kevin_reaction", :uuid) %> account: 37s_uuid content: "🎯" reactable: logo_uuid (Card) reacter: kevin_uuid logo_card_david: id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_reaction", :uuid) %> account: 37s_uuid content: "👍" reactable: logo_uuid (Card) reacter: david_uuid ================================================ FILE: test/fixtures/sessions.yml ================================================ david: identity: david kevin: identity: kevin jz: identity: jz jason: identity: jason mike: identity: mike ================================================ FILE: test/fixtures/taggings.yml ================================================ logo_web: id: <%= ActiveRecord::FixtureSet.identify("logo_web_tagging", :uuid) %> account: 37s_uuid card: logo_uuid tag: web_uuid layout_web: id: <%= ActiveRecord::FixtureSet.identify("layout_web_tagging", :uuid) %> account: 37s_uuid card: layout_uuid tag: web_uuid layout_mobile: id: <%= ActiveRecord::FixtureSet.identify("layout_mobile_tagging", :uuid) %> account: 37s_uuid card: layout_uuid tag: mobile_uuid text_mobile: id: <%= ActiveRecord::FixtureSet.identify("text_mobile_tagging", :uuid) %> account: 37s_uuid card: text_uuid tag: mobile_uuid ================================================ FILE: test/fixtures/tags.yml ================================================ web: id: <%= ActiveRecord::FixtureSet.identify("web", :uuid) %> title: web account: 37s_uuid mobile: id: <%= ActiveRecord::FixtureSet.identify("mobile", :uuid) %> title: mobile account: 37s_uuid ================================================ FILE: test/fixtures/user/settings.yml ================================================ _fixture: model_class: User::Settings david_settings: id: <%= ActiveRecord::FixtureSet.identify("david_settings", :uuid) %> account: 37s_uuid user: david_uuid bundle_email_frequency: never jz_settings: id: <%= ActiveRecord::FixtureSet.identify("jz_settings", :uuid) %> account: 37s_uuid user: jz_uuid bundle_email_frequency: never kevin_settings: id: <%= ActiveRecord::FixtureSet.identify("kevin_settings", :uuid) %> account: 37s_uuid user: kevin_uuid bundle_email_frequency: never system_settings: id: <%= ActiveRecord::FixtureSet.identify("system_settings", :uuid) %> account: 37s_uuid user: system_uuid bundle_email_frequency: never ================================================ FILE: test/fixtures/users.yml ================================================ david: id: <%= ActiveRecord::FixtureSet.identify("david", :uuid) %> name: David role: member identity: david account: 37s_uuid verified_at: <%= Time.current.to_fs(:db) %> jz: id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %> name: JZ role: member identity: jz account: 37s_uuid verified_at: <%= Time.current.to_fs(:db) %> jason: id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %> name: Jason role: owner identity: jason account: 37s_uuid verified_at: <%= Time.current.to_fs(:db) %> kevin: id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %> name: Kevin role: admin identity: kevin account: 37s_uuid verified_at: <%= Time.current.to_fs(:db) %> system: id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %> name: System role: system account: 37s_uuid mike: id: <%= ActiveRecord::FixtureSet.identify("mike", :uuid) %> name: Mike role: admin identity: mike account: initech_uuid verified_at: <%= Time.current.to_fs(:db) %> system_initech: id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %> name: System role: system account: initech_uuid ================================================ FILE: test/fixtures/watches.yml ================================================ logo_david: id: <%= ActiveRecord::FixtureSet.identify("logo_david_watch", :uuid) %> account: 37s_uuid card: logo_uuid user: david_uuid watching: true logo_kevin: id: <%= ActiveRecord::FixtureSet.identify("logo_kevin_watch", :uuid) %> account: 37s_uuid card: logo_uuid user: kevin_uuid watching: true layout_david: id: <%= ActiveRecord::FixtureSet.identify("layout_david_watch", :uuid) %> account: 37s_uuid card: layout_uuid user: david_uuid watching: true layout_kevin: id: <%= ActiveRecord::FixtureSet.identify("layout_kevin_watch", :uuid) %> account: 37s_uuid card: layout_uuid user: kevin_uuid watching: true text_david: id: <%= ActiveRecord::FixtureSet.identify("text_david_watch", :uuid) %> account: 37s_uuid card: text_uuid user: david_uuid watching: true text_jz: id: <%= ActiveRecord::FixtureSet.identify("text_jz_watch", :uuid) %> account: 37s_uuid card: text_uuid user: jz_uuid watching: true shipping_david: id: <%= ActiveRecord::FixtureSet.identify("shipping_david_watch", :uuid) %> account: 37s_uuid card: shipping_uuid user: david_uuid watching: true shipping_jz: id: <%= ActiveRecord::FixtureSet.identify("shipping_jz_watch", :uuid) %> account: 37s_uuid card: shipping_uuid user: jz_uuid watching: true shipping_kevin: id: <%= ActiveRecord::FixtureSet.identify("shipping_kevin_watch", :uuid) %> account: 37s_uuid card: shipping_uuid user: kevin_uuid watching: true ================================================ FILE: test/fixtures/webhook/delinquency_trackers.yml ================================================ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html active_webhook_tracker: id: <%= ActiveRecord::FixtureSet.identify("active_webhook_tracker", :uuid) %> account: 37s_uuid webhook: active_uuid consecutive_failures_count: 1 first_failure_at: <%= 1.hour.ago %> inactive_webhook_tracker: id: <%= ActiveRecord::FixtureSet.identify("inactive_webhook_tracker", :uuid) %> account: 37s_uuid webhook: inactive_uuid consecutive_failures_count: 1 first_failure_at: <%= 1.hour.ago %> ================================================ FILE: test/fixtures/webhook/deliveries.yml ================================================ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html successfully_completed: id: <%= ActiveRecord::FixtureSet.identify("successfully_completed_delivery", :uuid) %> account: 37s_uuid webhook: active_uuid event: logo_published_uuid state: completed request: '<%= { headers: {} }.to_json %>' response: '<%= { code: 200, headers: {} }.to_json %>' created_at: <%= 1.week.ago %> unsuccessfully_completed: id: <%= ActiveRecord::FixtureSet.identify("unsuccessfully_completed_delivery", :uuid) %> account: 37s_uuid webhook: active_uuid event: logo_assignment_jz_uuid state: completed request: '<%= { headers: {} }.to_json %>' response: '<%= { code: 422, headers: {} }.to_json %>' created_at: <%= 1.week.ago + 1.hour %> errored: id: <%= ActiveRecord::FixtureSet.identify("errored_delivery", :uuid) %> account: 37s_uuid webhook: active_uuid event: layout_published_uuid state: errored request: '<%= { headers: {} }.to_json %>' response: '<%= { error: "destination_unreachable" }.to_json %>' created_at: <%= 1.week.ago %> pending: id: <%= ActiveRecord::FixtureSet.identify("pending_delivery", :uuid) %> account: 37s_uuid webhook: active_uuid event: shipping_closed_uuid state: pending request: null response: null created_at: <%= 2.day.ago %> in_progress: id: <%= ActiveRecord::FixtureSet.identify("in_progress_delivery", :uuid) %> account: 37s_uuid webhook: active_uuid event: logo_assignment_km_uuid state: in_progress request: null response: null created_at: <%= 1.day.ago %> ================================================ FILE: test/fixtures/webhooks.yml ================================================ active: id: <%= ActiveRecord::FixtureSet.identify("active", :uuid) %> active: true name: Production API url: https://api.example.com/webhooks signing_secret: p94Bx2HjempCdYB4DTyZkY1b # gitleaks:allow randomly generated subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' board: writebook_uuid account: 37s_uuid inactive: id: <%= ActiveRecord::FixtureSet.identify("inactive", :uuid) %> active: false name: Test Webhook url: https://test.example.com/webhooks signing_secret: H8ms8ADcV92v2x17hnLEiL5m # gitleaks:allow randomly generated subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>' board: private_uuid account: 37s_uuid ================================================ FILE: test/helpers/.keep ================================================ ================================================ FILE: test/helpers/action_text_rendering_test.rb ================================================ require "test_helper" class ActionTextRenderingTest < ActionView::TestCase test "data-action attributes in user content are stripped" do malicious_html = <<~HTML

    Click here: malicious link

    HTML content = ActionText::Content.new(malicious_html) rendered = content.to_s assert_no_match(/data-action/, rendered) assert_match(/malicious link<\/a>/, rendered) end end ================================================ FILE: test/helpers/application_helper_test.rb ================================================ require "test_helper" class ApplicationHelperTest < ActionView::TestCase def parse(html) Nokogiri::HTML::DocumentFragment.parse(html) end test "page_title_tag on untenanted page" do Current.account = nil assert_select parse(page_title_tag), "title", text: "Fizzy" end test "page_title_tag on untenanted page with a page title" do @page_title = "Holodeck" Current.account = nil assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" end test "page_title_tag on tenanted page when user has a single account" do Current.session = sessions(:david) assert_select parse(page_title_tag), "title", text: "Fizzy" end test "page_title_tag on tenanted page when user has multiple accounts" do Current.session = sessions(:david) other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") identities(:david).users.create!(account: other_account, name: "David") assert_select parse(page_title_tag), "title", text: "37signals | Fizzy" end test "page_title_tag on tenanted page with a page title when user has a single account" do Current.session = sessions(:david) @page_title = "Holodeck" assert_select parse(page_title_tag), "title", text: "Holodeck | Fizzy" end test "page_title_tag on tenanted page with a page title when user has multiple account" do Current.session = sessions(:david) other_account = Account.create!(external_account_id: "dangling-tenant", name: "Other Account") identities(:david).users.create!(account: other_account, name: "David") @page_title = "Holodeck" assert_select parse(page_title_tag), "title", text: "Holodeck | 37signals | Fizzy" end end ================================================ FILE: test/helpers/entropy_helper_test.rb ================================================ require "test_helper" class EntropyHelperTest < ActionView::TestCase test "stalled_bubble_options_for returns nil when card has no activity spike" do assert_nil stalled_bubble_options_for(cards(:logo)) end test "stalled_bubble_options_for returns options when card has activity spike" do card = cards(:logo) card.create_activity_spike! options = stalled_bubble_options_for(card) assert_not_nil options assert_equal card.last_activity_spike_at.iso8601, options[:lastActivitySpikeAt] end test "stalled_bubble_options_for includes updatedAt for client-side staleness check" do card = cards(:logo) card.create_activity_spike! travel_to 3.months.from_now # Touch the card to simulate step completion card.touch options = stalled_bubble_options_for(card) # The helper must include updatedAt so JS can check if card was recently updated assert_equal card.updated_at.iso8601, options[:updatedAt] end end ================================================ FILE: test/helpers/excerpt_helper_test.rb ================================================ require "test_helper" class ExcerptHelperTest < ActionView::TestCase test "quote" do assert_excerpt("> Hello world", "> Hello world") end test "ul" do assert_excerpt("• Hello world", "- Hello world") assert_excerpt("• Hello world", " - Hello world") end test "ol" do assert_excerpt("99. Hello world", "99. Hello world") end test "large spaces" do assert_excerpt("Hello world", " Hello world ") end test "long text" do assert_excerpt("A"*197 + "...", "A"*1000) assert_excerpt("A"*97 + "...", "A"*1000, length: 100) end private def assert_excerpt(expected, content, ...) assert_equal expected, format_excerpt(ActionText::Content.new(content), ...), "Excerpt of Action Text Content does not match" assert_equal expected, format_excerpt(content, ...), "Excerpt of String does not match" end end ================================================ FILE: test/helpers/hotkeys_helper_test.rb ================================================ require "test_helper" class HotkeysHelperTest < ActionView::TestCase include SetPlatform test "mac modifier key" do emulate_mac assert_equal "⌘J", hotkey_label([ "⌘", "J" ]) end test "linux modifier key" do emulate_linux assert_equal "Ctrl+J", hotkey_label([ "ctrl", "J" ]) end test "mac enter" do emulate_mac assert_equal "Return+J", hotkey_label([ "enter", "J" ]) end test "linux enter" do emulate_linux assert_equal "Enter+J", hotkey_label([ "enter", "J" ]) end test "mac hyper" do emulate_mac assert_equal "Hyper+J", hotkey_label([ "hyper", "J" ]) end test "linux hyper" do emulate_linux assert_equal "Hyper+J", hotkey_label([ "hyper", "J" ]) end private def emulate_mac stub_platform = ApplicationPlatform.new("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36") self.stubs(:platform).returns(stub_platform) end def emulate_linux stub_platform = ApplicationPlatform.new("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36") self.stubs(:platform).returns(stub_platform) end end ================================================ FILE: test/helpers/html_helper_test.rb ================================================ require "test_helper" class HtmlHelperTest < ActionView::TestCase test "convert URLs into anchor tags" do assert_equal_html \ %(

    Check this: https://example.com

    ), format_html("

    Check this: https://example.com

    ") assert_equal_html \ %(

    Check this: https://example.com/

    ), format_html("

    Check this: https://example.com/

    ") end test "convert multiple URLs in the same string" do assert_equal_html \ %(Visit https://foo.com/. Also see https://bar.com/!), format_html("Visit https://foo.com/. Also see https://bar.com/!") end test "don't include punctuation in URL autolinking" do assert_equal_html \ %(

    Check this: https://example.com/!

    ), format_html("

    Check this: https://example.com/!

    ") assert_equal_html \ %(

    Check this: https://example.com/.

    ), format_html("

    Check this: https://example.com/.

    ") assert_equal_html \ %(

    Check this: https://example.com/?

    ), format_html("

    Check this: https://example.com/?

    ") assert_equal_html \ %(

    Check this: https://example.com/,

    ), format_html("

    Check this: https://example.com/,

    ") assert_equal_html \ %(

    Check this: https://example.com/:

    ), format_html("

    Check this: https://example.com/:

    ") assert_equal_html \ %(

    Check this: https://example.com/;

    ), format_html("

    Check this: https://example.com/;

    ") assert_equal_html \ %(

    Check this: https://example.com/"

    ), format_html("

    Check this: https://example.com/\"

    ") assert_equal_html \ %(

    Check this: https://example.com/'

    ), format_html("

    Check this: https://example.com/'

    ") # trailing entities that decode to punctuation # use assert_equal and not assert_equal_html to make sure we're getting entities correct assert_equal \ %(

    Check this: https://example.com/<

    ), format_html("

    Check this: https://example.com/<

    ") assert_equal \ %(

    Check this: https://example.com/>

    ), format_html("

    Check this: https://example.com/>

    ") assert_equal \ %(

    Check this: https://example.com/"

    ), format_html("

    Check this: https://example.com/"

    ") # multiple punctuation characters including entities assert_equal_html \ %(

    Check this: https://example.com/!?;

    ), format_html("

    Check this: https://example.com/!?;

    ") assert_equal_html \ %(<img src="https://example.com/">), format_html(%(<img src="https://example.com/">)) assert_equal_html \ %(<img src="https://example.com/"!>), format_html(%(<img src="https://example.com/"!>)) end test "make sure the linked content is properly sanitized" do # https://hackerone.com/reports/3481093 result = format_html(%(https://google.com/\">test</a><input></input>)) assert_no_match(//i, result, "should not create an input element") result = format_html(%(https://google.com/\"><script>alert('xss')</script>)) assert_no_match(/" }) end test "card_html_title escapes HTML inside backticks" do assert_equal "<script>", card_html_title(cards(:logo).tap { _1.title = "`") description = events(:logo_published).description_for(users(:david)) assert_includes description.to_plain_text, "<script>alert('xss')</script>" assert_not_includes description.to_plain_text, "") assert_equal "<script>#{mark('test')}</script>", result end private def mark(text) "#{Search::Highlighter::OPENING_MARK}#{text}#{Search::Highlighter::CLOSING_MARK}" end end ================================================ FILE: test/models/search/stemmer_test.rb ================================================ require "test_helper" class Search::StemmerTest < ActiveSupport::TestCase test "stem single word" do result = Search::Stemmer.stem("running") assert_equal "run", result end test "stem multiple words" do result = Search::Stemmer.stem("test, running JUMPING & walking") assert_equal "test run jump walk", result end test "stem hyphenated words" do result = Search::Stemmer.stem("BC3-IOS-1D8B") assert_equal "bc3 io 1d8b", result end test "stem words separated by repeated punctuation" do result = Search::Stemmer.stem("foo---bar") assert_equal "foo bar", result end end ================================================ FILE: test/models/search_test.rb ================================================ require "test_helper" class SearchTest < ActiveSupport::TestCase include SearchTestHelper test "search" do # Search cards and comments card = @board.cards.create!(title: "layout design", creator: @user, status: "published") comment_card = @board.cards.create!(title: "Some card", creator: @user, status: "published") comment_card.comments.create!(body: "overflowing text", creator: @user) results = Search::Record.for(@user.account_id).search("layout", user: @user) assert results.find { |it| it.card_id == card.id } results = Search::Record.for(@user.account_id).search("overflowing", user: @user) assert results.find { |it| it.card_id == comment_card.id && it.searchable_type == "Comment" } # Drafted cards are excluded from search results drafted_card = @board.cards.create!(title: "drafted searchable content", creator: @user, status: "drafted") results = Search::Record.for(@user.account_id).search("drafted", user: @user) assert_not results.find { |it| it.card_id == drafted_card.id } # Don't include inaccessible boards other_user = User.create!(name: "Other User", account: @account) inaccessible_board = Board.create!(name: "Inaccessible Board", account: @account, creator: other_user) accessible_card = @board.cards.create!(title: "searchable content", creator: @user, status: "published") inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", creator: other_user, status: "published") results = Search::Record.for(@user.account_id).search("searchable", user: @user) assert results.find { |it| it.card_id == accessible_card.id } assert_not results.find { |it| it.card_id == inaccessible_card.id } # Empty board_ids returns no results user_without_access = User.create!(name: "No Access User", account: @account) results = Search::Record.for(user_without_access.account_id).search("anything", user: user_without_access) assert_empty results end test "search for hyphenated strings" do card = @board.cards.create!(title: "BC3-IOS-1D8B", creator: @user, status: "published") results = Search::Record.for(@user.account_id).search("BC3-IOS-1D8B", user: @user) assert results.find { |it| it.card_id == card.id } end end ================================================ FILE: test/models/signup/account_name_generator_test.rb ================================================ require "test_helper" class Signup::AccountNameGeneratorTest < ActiveSupport::TestCase setup do @identity = Identity.create!(email_address: "newart.userbaum@example.com") @name = "Newart userbaum" @generator = Signup::AccountNameGenerator.new(identity: @identity, name: @name) end test "generate" do account_name = @generator.generate assert_equal "Newart's Fizzy", account_name, "The 1st account doesn't have 1st in the name" first_account = Account.create!(external_account_id: "1st", name: account_name) Current.without_account do @identity.users.create!(account: first_account, name: @name) @identity.reload end account_name = @generator.generate assert_equal "Newart's 2nd Fizzy", account_name second_account = Account.create!(external_account_id: "2nd", name: account_name) Current.without_account do @identity.users.create!(account: second_account, name: @name) @identity.reload end account_name = @generator.generate assert_equal "Newart's 3rd Fizzy", account_name third_account = Account.create!(external_account_id: "3rd", name: account_name) Current.without_account do @identity.users.create!(account: third_account, name: @name) @identity.reload end account_name = @generator.generate assert_equal "Newart's 4th Fizzy", account_name fourth_account = Account.create!(external_account_id: "4th", name: account_name) Current.without_account do @identity.users.create!(account: fourth_account, name: @name) @identity.reload end account_name = @generator.generate assert_equal "Newart's 5th Fizzy", account_name end test "generate continues from the previous highest index" do account = Account.create!(external_account_id: "12th", name: "Newart's 12th Fizzy") Current.without_account do @identity.users.create!(account: account, name: @name) @identity.reload end account_name = @generator.generate assert_equal "Newart's 13th Fizzy", account_name end end ================================================ FILE: test/models/signup_test.rb ================================================ require "test_helper" class SignupTest < ActiveSupport::TestCase test "validates email format for identity creation" do signup = Signup.new(email_address: "not-an-email") assert_not signup.valid?(:identity_creation) assert signup.errors[:email_address].any? signup = Signup.new(email_address: "valid@example.com") assert signup.valid?(:identity_creation) end test "#create_identity" do signup = Signup.new(email_address: "brian@example.com") magic_link = nil assert_difference -> { Identity.count }, 1 do assert_difference -> { MagicLink.count }, 1 do magic_link = signup.create_identity end end assert_kind_of MagicLink, magic_link assert_empty signup.errors assert signup.identity assert signup.identity.persisted? signup_existing = Signup.new(email_address: "brian@example.com") assert_no_difference -> { Identity.count } do assert_difference -> { MagicLink.count }, 1 do magic_link = signup_existing.create_identity end end assert_kind_of MagicLink, magic_link signup_invalid = Signup.new(email_address: "") assert_raises do signup_invalid.create_identity end end test "#complete" do Account.any_instance.expects(:setup_customer_template).once Current.without_account do signup = Signup.new(full_name: "Kevin", identity: identities(:kevin)) assert signup.complete assert signup.account assert signup.user assert_equal "Kevin", signup.user.name signup_invalid = Signup.new( full_name: "", identity: identities(:kevin) ) assert_not signup_invalid.complete assert_not_empty signup_invalid.errors[:full_name] end end test "#complete with invalid data" do Current.without_account do signup = Signup.new assert_not signup.complete assert signup.errors[:full_name].any? assert signup.errors[:identity].any? assert_nil signup.account assert_nil signup.user end end test "#complete with name that is too long" do Current.without_account do signup = Signup.new(full_name: "A" * 241, identity: identities(:kevin)) signup.expects(:create_tenant).never assert_not signup.complete assert signup.errors[:full_name].any? assert_nil signup.account assert_nil signup.user end end end ================================================ FILE: test/models/ssrf_protection_test.rb ================================================ require "test_helper" class SsrfProtectionTest < ActiveSupport::TestCase test "blocks loopback addresses" do stub_dns_resolution("127.0.0.1") assert_nil SsrfProtection.resolve_public_ip("localhost") end test "blocks private 10.x.x.x addresses" do stub_dns_resolution("10.0.0.1") assert_nil SsrfProtection.resolve_public_ip("internal.example.com") end test "blocks private 172.16.x.x addresses" do stub_dns_resolution("172.16.0.1") assert_nil SsrfProtection.resolve_public_ip("internal.example.com") end test "blocks private 192.168.x.x addresses" do stub_dns_resolution("192.168.1.1") assert_nil SsrfProtection.resolve_public_ip("internal.example.com") end test "blocks link-local addresses (AWS metadata endpoint)" do stub_dns_resolution("169.254.169.254") assert_nil SsrfProtection.resolve_public_ip("metadata.example.com") end test "blocks carrier-grade NAT addresses" do stub_dns_resolution("100.64.0.1") assert_nil SsrfProtection.resolve_public_ip("cgnat.example.com") end test "blocks benchmark testing addresses" do stub_dns_resolution("198.18.0.1") assert_nil SsrfProtection.resolve_public_ip("benchmark.example.com") end test "blocks broadcast addresses" do stub_dns_resolution("0.0.0.1") assert_nil SsrfProtection.resolve_public_ip("broadcast.example.com") end test "allows public addresses" do stub_dns_resolution("93.184.216.34") assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("example.com") end test "returns first public IP when multiple addresses resolve" do stub_dns_resolution("10.0.0.1", "93.184.216.34", "192.168.1.1") assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com") end # IPv6 address format tests (SSRF bypass prevention) test "blocks IPv4-mapped IPv6 addresses with private IPs" do stub_dns_resolution("::ffff:192.168.1.1") assert_nil SsrfProtection.resolve_public_ip("mapped-private.example.com") end test "blocks IPv4-mapped IPv6 addresses with link-local IPs" do stub_dns_resolution("::ffff:169.254.169.254") assert_nil SsrfProtection.resolve_public_ip("mapped-metadata.example.com") end test "blocks IPv4-mapped IPv6 addresses even with public IPs" do stub_dns_resolution("::ffff:93.184.216.34") assert_nil SsrfProtection.resolve_public_ip("mapped-public.example.com") end test "blocks IPv4-compatible IPv6 addresses with private IPs" do stub_dns_resolution("::192.168.1.1") assert_nil SsrfProtection.resolve_public_ip("compat-private.example.com") end test "blocks IPv4-compatible IPv6 addresses with link-local IPs" do stub_dns_resolution("::169.254.169.254") assert_nil SsrfProtection.resolve_public_ip("compat-metadata.example.com") end test "blocks IPv4-compatible IPv6 addresses even with public IPs" do stub_dns_resolution("::93.184.216.34") assert_nil SsrfProtection.resolve_public_ip("compat-public.example.com") end private def stub_dns_resolution(*ips) dns_mock = mock("dns") dns_mock.stubs(:each_address).multiple_yields(*ips) Resolv::DNS.stubs(:open).yields(dns_mock) end end ================================================ FILE: test/models/storage/attachment_tracking_test.rb ================================================ require "test_helper" class Storage::AttachmentTrackingTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) Current.request_id = "test-request-123" @account = accounts("37s") @board = boards(:writebook) @card = cards(:logo) end # Attachment Creation test "attaching file creates storage entry with positive delta" do assert_difference "Storage::Entry.count", +1 do @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" end entry = Storage::Entry.last assert_equal 2048, entry.delta assert_equal "attach", entry.operation assert_equal @account.id, entry.account_id assert_equal @board.id, entry.board_id assert_equal @card.class.name, entry.recordable_type assert_equal @card.id, entry.recordable_id assert_equal @card.image.blob.id, entry.blob_id assert_equal Current.user.id, entry.user_id assert_equal Current.request_id, entry.request_id end test "attaching file enqueues MaterializeJob for account" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" end end test "attaching file enqueues MaterializeJob for board" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" end end # Attachment Deletion test "destroying attachment creates storage entry with negative delta" do @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" attachment = @card.image.attachment blob_id = attachment.blob_id # Destroy the attachment directly to trigger callbacks attachment.destroy! entry = Storage::Entry.find_by(operation: "detach", recordable: @card) assert_not_nil entry, "Expected detach entry to be created" assert_equal -2048, entry.delta assert_equal "detach", entry.operation assert_equal blob_id, entry.blob_id end test "destroying attachment uses snapshotted IDs from before_destroy" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" # Capture expected values before destroy expected_account_id = @account.id expected_board_id = @board.id expected_recordable_type = @card.class.name expected_recordable_id = @card.id attachment = @card.image.attachment attachment.destroy! entry = Storage::Entry.find_by(operation: "detach", recordable_id: expected_recordable_id) assert_not_nil entry, "Expected detach entry to be created" assert_equal expected_account_id, entry.account_id assert_equal expected_board_id, entry.board_id assert_equal expected_recordable_type, entry.recordable_type assert_equal expected_recordable_id, entry.recordable_id end # Non-Trackable Records test "does not track attachments on records without account method" do # Account uploads are not trackable (Account.account returns self, but # uploads on Account are not board-scoped in the same way) # This test verifies the guard clause works # Create a model that doesn't respond to :board identity = identities(:david) # Identity doesn't have :account or :board, so attachments shouldn't be tracked # (Though in practice, Identity may not have attachments in this codebase) # We test the guard by checking that the tracking module handles non-trackable records assert_respond_to @card, :account assert_respond_to @card, :board end # Edge Cases test "attachment tracking handles nil board gracefully" do # Create a card with nil board association won't happen in practice # but test that entry creation handles nil board_id @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" entry = Storage::Entry.last assert_not_nil entry.account_id # board_id should be present for cards assert_not_nil entry.board_id end test "replacing attachment creates detach and attach entries" do # First attachment @card.image.attach io: StringIO.new("x" * 1024), filename: "first.png", content_type: "image/png" initial_count = Storage::Entry.count # Replace with new attachment @card.image.attach io: StringIO.new("x" * 2048), filename: "second.png", content_type: "image/png" # Should have detach (-1024) and attach (+2048) entries # Note: depending on purge_later vs purge, the detach might be async entries = Storage::Entry.where(recordable: @card).order(:id).last(2) # At minimum, we should have the new attach entry attach_entry = entries.find { |e| e.operation == "attach" && e.delta == 2048 } assert_not_nil attach_entry end # Rich Text Embeds # # ActionText embeds are automatically extracted from body content that contains # tags referencing ActiveStorage::Blob objects. # The embeds association is populated during before_validation callback. test "card description embed creates storage entry" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "card_embed.jpg", content_type: "image/jpeg" # Create rich text content with embedded blob attachment attachment_html = ActionText::Attachment.from_attachable(blob).to_html assert_difference "Storage::Entry.count", +1 do @card.update!(description: "

    Description with image: #{attachment_html}

    ") end entry = Storage::Entry.last assert_equal blob.byte_size, entry.delta assert_equal "attach", entry.operation assert_equal "Card", entry.recordable_type assert_equal @card.id, entry.recordable_id end test "comment embed creates storage entry via rich text body" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "comment_image.jpg", content_type: "image/jpeg" attachment_html = ActionText::Attachment.from_attachable(blob).to_html assert_difference "Storage::Entry.count", +1 do @card.comments.create!(body: "

    Comment with image: #{attachment_html}

    ") end entry = Storage::Entry.last assert_equal blob.byte_size, entry.delta assert_equal "attach", entry.operation assert_equal @account.id, entry.account_id assert_equal @board.id, entry.board_id assert_equal "Comment", entry.recordable_type end test "comment embed uses card's board for tracking" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "test.jpg", content_type: "image/jpeg" attachment_html = ActionText::Attachment.from_attachable(blob).to_html comment = @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") entry = Storage::Entry.last assert_equal @card.board_id, entry.board_id assert_equal comment.id, entry.recordable_id end test "board public_description embed creates storage entry" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "board_image.jpg", content_type: "image/jpeg" attachment_html = ActionText::Attachment.from_attachable(blob).to_html assert_difference "Storage::Entry.count", +1 do @board.update!(public_description: "

    Board description: #{attachment_html}

    ") end entry = Storage::Entry.last assert_equal blob.byte_size, entry.delta assert_equal "attach", entry.operation assert_equal @account.id, entry.account_id assert_equal @board.id, entry.board_id assert_equal "Board", entry.recordable_type assert_equal @board.id, entry.recordable_id end # Reconciliation includes all attachment types test "board calculate_real_storage_bytes includes comment embeds" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "comment_embed.jpg", content_type: "image/jpeg" attachment_html = ActionText::Attachment.from_attachable(blob).to_html @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") board_bytes = @board.send(:calculate_real_storage_bytes) assert board_bytes >= blob.byte_size, "board bytes should include comment embed bytes" end test "account calculate_real_storage_bytes includes comment embeds via boards" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "comment_embed.jpg", content_type: "image/jpeg" attachment_html = ActionText::Attachment.from_attachable(blob).to_html @card.comments.create!(body: "

    Comment: #{attachment_html}

    ") account_bytes = @account.send(:calculate_real_storage_bytes) assert account_bytes >= blob.byte_size, "account bytes should include comment embed bytes" end # Cascading Deletes test "attachment tracking handles card deletion gracefully" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" card_id = @card.id # Delete the card - this should trigger attachment purge # The before_destroy snapshot should capture IDs before card is gone perform_enqueued_jobs do assert_nothing_raised do @card.destroy! end end # Should have detach entry with snapshotted IDs detach_entry = Storage::Entry.find_by(recordable_id: card_id, operation: "detach") assert_not_nil detach_entry, "Expected detach entry for destroyed card" assert_equal -1024, detach_entry.delta end end ================================================ FILE: test/models/storage/entry_test.rb ================================================ require "test_helper" class Storage::EntryTest < ActiveSupport::TestCase setup do @account = accounts("37s") @board = boards(:writebook) @card = cards(:logo) end test "record creates entry with positive delta" do assert_difference "Storage::Entry.count", +1 do entry = Storage::Entry.record \ account: @account, board: @board, recordable: @card, delta: 1024, operation: "attach" assert_equal @account.id, entry.account_id assert_equal @board.id, entry.board_id assert_equal @card.class.name, entry.recordable_type assert_equal @card.id, entry.recordable_id assert_equal 1024, entry.delta assert_equal "attach", entry.operation end end test "record creates entry with negative delta" do entry = Storage::Entry.record \ account: @account, board: @board, recordable: @card, delta: -512, operation: "detach" assert_equal -512, entry.delta assert_equal "detach", entry.operation end test "record returns nil and creates no entry when delta is zero" do assert_no_difference "Storage::Entry.count" do result = Storage::Entry.record \ account: @account, board: @board, recordable: @card, delta: 0, operation: "attach" assert_nil result end end test "record works with destroyed records (destroyed? check)" do # Simulate a destroyed record - .id still works after destroy @card.destroy entry = Storage::Entry.record \ account: @account, board: @board, recordable: @card, delta: 2048, operation: "detach" assert_equal @account.id, entry.account_id assert_equal @board.id, entry.board_id assert_equal "Card", entry.recordable_type assert_equal @card.id, entry.recordable_id end test "record creates entry without board" do entry = Storage::Entry.record \ account: @account, board: nil, recordable: @card, delta: 1024, operation: "attach" assert_nil entry.board_id end test "record creates entry without recordable" do entry = Storage::Entry.record \ account: @account, board: @board, recordable: nil, delta: 1024, operation: "reconcile" assert_nil entry.recordable_type assert_nil entry.recordable_id end test "record enqueues MaterializeJob for account" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do Storage::Entry.record \ account: @account, board: nil, recordable: nil, delta: 1024, operation: "attach" end end test "record enqueues MaterializeJob for board when board_id present" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do Storage::Entry.record \ account: @account, board: @board, recordable: nil, delta: 1024, operation: "attach" end end test "record skips entirely when account is destroyed" do # No need to track storage for deleted accounts @account.destroy assert_no_difference "Storage::Entry.count" do result = Storage::Entry.record \ account: @account, delta: 1024, operation: "attach" assert_nil result end end test "record does not enqueue board job when board is destroyed" do @board.destroy # Account job still enqueued, but destroyed board skips its job jobs = [] assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do Storage::Entry.record \ account: @account, board: @board, delta: 1024, operation: "attach" end # Verify board job was NOT enqueued board_jobs = enqueued_jobs.select { |j| j["arguments"].include?(@board.to_global_id.to_s) } assert_empty board_jobs end test "entries belong to account" do entry = Storage::Entry.record \ account: @account, delta: 1024, operation: "attach" assert_equal @account, entry.account end test "entries belong to board (optional)" do entry = Storage::Entry.record \ account: @account, board: @board, delta: 1024, operation: "attach" assert_equal @board, entry.board end test "entries belong to recordable (polymorphic, optional)" do entry = Storage::Entry.record \ account: @account, recordable: @card, delta: 1024, operation: "attach" assert_equal @card, entry.recordable end end ================================================ FILE: test/models/storage/no_reuse_test.rb ================================================ require "test_helper" class Storage::NoReuseTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) @account = accounts("37s") Current.account = @account # Ensure blobs get correct account_id @board = @account.boards.create!(name: "Test", creator: users(:david)) end # No-reuse validation # NOTE: For persisted records, ActiveStorage::Attached::One#attach raises # ActiveRecord::RecordNotSaved when validation fails. test "rejects attaching blob that already has tracked attachment" do blob = ActiveStorage::Blob.create_and_upload! \ io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" # First attachment succeeds card1 = @board.cards.create!(title: "Card 1", creator: users(:david)) card1.image.attach(blob) assert card1.image.attached? # Second attachment of same blob fails card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) assert_raises ActiveRecord::RecordNotSaved do card2.image.attach(blob) end # Verify only one attachment exists for this blob assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count end test "allows reuse for ActionText embeds" do file = file_fixture("moon.jpg") blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html card1 = @board.cards.create!(title: "Card 1", creator: users(:david)) card1.update!(description: "

    #{embed_html}

    ") card1.reload card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) card2.update!(description: "

    #{embed_html}

    ") card2.reload assert_equal 2, ActiveStorage::Attachment.where( record_type: "ActionText::RichText", name: "embeds", blob_id: blob.id ).count end test "purge_later does not purge blob when still attached elsewhere" do file = file_fixture("moon.jpg") blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html card1 = @board.cards.create!(title: "Card 1", creator: users(:david)) card1.update!(description: "

    #{embed_html}

    ") card2 = @board.cards.create!(title: "Card 2", creator: users(:david)) card2.update!(description: "

    #{embed_html}

    ") attachment = ActiveStorage::Attachment.find_by( record: card1.rich_text_description, name: "embeds", blob_id: blob.id ) assert_no_enqueued_jobs only: ActiveStorage::PurgeJob do attachment.purge_later end assert ActiveStorage::Blob.exists?(blob.id) assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count end test "purge_later enqueues purge when last attachment is removed" do file = file_fixture("moon.jpg") blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html card = @board.cards.create!(title: "Card", creator: users(:david)) card.update!(description: "

    #{embed_html}

    ") card.reload attachment = ActiveStorage::Attachment.find_by( record: card.rich_text_description, name: "embeds", blob_id: blob.id ) assert_enqueued_with job: ActiveStorage::PurgeJob, args: [ blob ] do attachment.purge_later end end test "rejects cross-account blob attachment" do other_account = Account.create!(name: "Other") other_board = other_account.boards.create!(name: "Other Board", creator: users(:david)) # Blob created in @account context blob = ActiveStorage::Blob.create_and_upload! \ io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" card = other_board.cards.create!(title: "Card", creator: users(:david)) assert_raises ActiveRecord::RecordNotSaved do card.image.attach(blob) end # Verify attachment was not created (blob account doesn't match record account) assert_not card.reload.image.attached? end test "allows attaching blob to untracked record type" do file = file_fixture("moon.jpg") blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "avatar.jpg", content_type: "image/jpeg" # User avatar is not a tracked record type user = users(:david) user.avatar.attach(blob) # Should succeed - avatars are not storage-tracked assert user.avatar.attached? end test "allows multiple attachments of same blob to untracked record types" do file = file_fixture("moon.jpg") blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "avatar.jpg", content_type: "image/jpeg" # First attachment to untracked (avatar) user1 = users(:david) user1.avatar.attach(blob) assert user1.avatar.attached? # Second attachment to untracked (another avatar) should work # since no-reuse only checks tracked contexts user2 = users(:jz) user2.avatar.attach(blob) assert user2.avatar.attached? end end ================================================ FILE: test/models/storage/total_test.rb ================================================ require "test_helper" class Storage::TotalTest < ActiveSupport::TestCase setup do @account = accounts("37s") @board = boards(:writebook) end test "pending_entries returns all entries when no cursor" do # Create some entries 3.times do |i| Storage::Entry.record \ account: @account, delta: 1024 * (i + 1), operation: "attach" end total = @account.create_storage_total! assert_nil total.last_entry_id assert_equal 3, total.pending_entries.count end test "pending_entries returns only entries after cursor" do # Create first entry and set cursor entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024) # Advance time to ensure UUIDv7 timestamps sort correctly travel 1.second # Create more entries AFTER cursor is set entry2 = Storage::Entry.record(account: @account, delta: 2048, operation: "attach") travel 1.second entry3 = Storage::Entry.record(account: @account, delta: 512, operation: "attach") pending = total.pending_entries assert_equal 2, pending.count assert_includes pending, entry2 assert_includes pending, entry3 assert_not_includes pending, entry1 end test "current_usage returns snapshot value when no pending entries" do total = @account.create_storage_total!(bytes_stored: 5000) # No entries exist, so nothing pending assert_equal 5000, total.current_usage end test "current_usage sums snapshot and pending entries" do # Create first entry and set cursor entry1 = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") total = @account.create_storage_total!(last_entry_id: entry1.id, bytes_stored: 1024) # Small delay to ensure UUIDv7 timestamp component advances travel 1.second # Create more entries AFTER cursor is set Storage::Entry.record(account: @account, delta: 2048, operation: "attach") travel 1.second Storage::Entry.record(account: @account, delta: -512, operation: "detach") # 1024 (snapshot) + 2048 - 512 (pending) = 2560 assert_equal 2560, total.current_usage end test "belongs to owner polymorphically" do account_total = Storage::Total.create!(owner: @account) assert_equal @account, account_total.owner board_total = Storage::Total.create!(owner: @board) assert_equal @board, board_total.owner end test "unique constraint on owner" do Storage::Total.create!(owner: @account) assert_raises ActiveRecord::RecordNotUnique do Storage::Total.create!(owner: @account) end end end ================================================ FILE: test/models/storage/totaled_test.rb ================================================ require "test_helper" class Storage::TotaledTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) @account = accounts("37s") @board = boards(:writebook) end # bytes_used (fast snapshot) test "bytes_used returns 0 when no storage_total exists" do assert_nil @account.storage_total assert_equal 0, @account.bytes_used end test "bytes_used returns snapshot value" do @account.create_storage_total!(bytes_stored: 10_000) assert_equal 10_000, @account.bytes_used end test "bytes_used does not include pending entries (fast path)" do @account.create_storage_total!(bytes_stored: 1000) # Create pending entry (not materialized) Storage::Entry.record(account: @account, delta: 500, operation: "attach") # bytes_used is fast path - only reads snapshot assert_equal 1000, @account.bytes_used end # bytes_used_exact (snapshot + pending) test "bytes_used_exact creates storage_total if missing" do assert_nil @account.storage_total @account.bytes_used_exact assert_not_nil @account.reload.storage_total end test "bytes_used_exact includes pending entries" do # Create first entry and set cursor at that entry entry = Storage::Entry.record(account: @account, delta: 500, operation: "attach") @account.create_storage_total!(bytes_stored: 500, last_entry_id: entry.id) # Small delay to ensure UUIDv7 timestamp advances travel 1.second # Create pending entry AFTER cursor Storage::Entry.record(account: @account, delta: 256, operation: "attach") # 500 (snapshot) + 256 (pending) = 756 assert_equal 756, @account.bytes_used_exact end test "bytes_used_exact returns 0 when no entries and no snapshot" do assert_equal 0, @account.bytes_used_exact end # materialize_storage test "materialize_storage creates storage_total if missing" do assert_nil @account.storage_total Storage::Entry.record(account: @account, delta: 1024, operation: "attach") @account.materialize_storage total = @account.reload.storage_total assert_not_nil total assert_equal 1024, total.bytes_stored end test "materialize_storage processes all pending entries" do Storage::Entry.record(account: @account, delta: 1000, operation: "attach") Storage::Entry.record(account: @account, delta: 2000, operation: "attach") Storage::Entry.record(account: @account, delta: -500, operation: "detach") @account.materialize_storage assert_equal 2500, @account.storage_total.bytes_stored assert_equal 0, @account.storage_total.pending_entries.count end test "materialize_storage updates cursor to latest entry" do entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach") entry2 = Storage::Entry.record(account: @account, delta: 500, operation: "attach") @account.materialize_storage assert_equal entry2.id, @account.storage_total.last_entry_id end test "materialize_storage is idempotent when no new entries" do Storage::Entry.record(account: @account, delta: 1000, operation: "attach") @account.materialize_storage initial_bytes = @account.storage_total.bytes_stored initial_cursor = @account.storage_total.last_entry_id @account.materialize_storage assert_equal initial_bytes, @account.storage_total.bytes_stored assert_equal initial_cursor, @account.storage_total.last_entry_id end test "materialize_storage processes only entries since cursor" do entry1 = Storage::Entry.record(account: @account, delta: 1000, operation: "attach") @account.materialize_storage assert_equal 1000, @account.storage_total.bytes_stored # Small delay to ensure UUIDv7 timestamp advances travel 1.second # Add more entries Storage::Entry.record(account: @account, delta: 500, operation: "attach") @account.materialize_storage assert_equal 1500, @account.storage_total.bytes_stored end test "materialize_storage does nothing when no entries" do @account.materialize_storage total = @account.reload.storage_total assert_not_nil total assert_equal 0, total.bytes_stored assert_nil total.last_entry_id end test "materialize_storage handles concurrent calls safely" do # Pre-create storage_total to avoid unique constraint race @account.create_storage_total! Storage::Entry.record(account: @account, delta: 1000, operation: "attach") # Simulate concurrent materialization threads = 3.times.map do Thread.new do ActiveRecord::Base.connection_pool.with_connection do @account.materialize_storage end end end threads.each(&:join) # Should still have correct total assert_equal 1000, @account.reload.storage_total.bytes_stored end # storage_entries association test "account has storage_entries association" do entry = Storage::Entry.record(account: @account, delta: 1024, operation: "attach") assert_includes @account.storage_entries, entry end test "board has storage_entries association" do entry = Storage::Entry.record(account: @account, board: @board, delta: 1024, operation: "attach") assert_includes @board.storage_entries, entry end # storage_total association test "storage_total is destroyed when owner is destroyed" do @account.create_storage_total!(bytes_stored: 1000) total_id = @account.storage_total.id # Create a new account to destroy (don't destroy fixtures) new_account = Account.create!(name: "Temp Account") new_account.create_storage_total!(bytes_stored: 500) storage_total_id = new_account.storage_total.id new_account.destroy! assert_not Storage::Total.exists?(storage_total_id) end # Board-specific tests test "board bytes_used works independently of account" do # Create entries for both account and board Storage::Entry.record(account: @account, board: nil, delta: 1000, operation: "attach") Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach") @account.materialize_storage @board.materialize_storage # Account sees all its entries (1000 + 500 = 1500) assert_equal 1500, @account.bytes_used # Board only sees entries with its board_id (500) assert_equal 500, @board.bytes_used end test "board and account have independent cursors" do entry1 = Storage::Entry.record(account: @account, board: @board, delta: 1000, operation: "attach") @account.materialize_storage # Board not yet materialized entry2 = Storage::Entry.record(account: @account, board: @board, delta: 500, operation: "attach") # Account cursor at entry1, board has no cursor yet assert_equal entry1.id, @account.storage_total.last_entry_id @board.materialize_storage # Board cursor now at entry2 assert_equal entry2.id, @board.storage_total.last_entry_id assert_equal 1500, @board.bytes_used end # reconcile_storage test "reconcile_storage creates entry for drift" do board = @account.boards.create!(name: "Test Board", creator: users(:david)) card = board.cards.create!(title: "Test Card", creator: users(:david)) card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" # Delete entry to simulate drift Storage::Entry.where(board: board).delete_all assert_difference "Storage::Entry.count", +1 do board.reconcile_storage end entry = Storage::Entry.find_by(board: board, operation: "reconcile") assert_equal 1000, entry.delta end test "reconcile_storage no-op when ledger matches reality" do board = @account.boards.create!(name: "Test Board", creator: users(:david)) card = board.cards.create!(title: "Test Card", creator: users(:david)) card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" assert_no_difference "Storage::Entry.where(operation: 'reconcile').count" do board.reconcile_storage end end test "reconcile_storage handles empty board" do board = @account.boards.create!(name: "Empty Board", creator: users(:david)) assert_no_difference "Storage::Entry.count" do board.reconcile_storage end end test "reconcile_storage handles negative drift" do board = @account.boards.create!(name: "Test Board", creator: users(:david)) # Create fake ledger entry with no real attachment Storage::Entry.create! \ account_id: @account.id, board_id: board.id, delta: 5000, operation: "attach" board.reconcile_storage entry = Storage::Entry.find_by(board: board, operation: "reconcile") assert_not_nil entry assert_equal(-5000, entry.delta) end test "reconcile_storage aborts when entry added during scan" do board = @account.boards.create!(name: "Test Board", creator: users(:david)) card = board.cards.create!(title: "Test Card", creator: users(:david)) card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png" # Delete entry to create drift Storage::Entry.where(board: board).delete_all # Intercept calculate_real_storage_bytes to insert a new entry mid-scan, # faithfully simulating a concurrent upload that changes the cursor board.define_singleton_method(:calculate_real_storage_bytes) do Storage::Entry.create!( account_id: account.id, board_id: id, delta: 500, operation: "attach" ) super() end # Should abort and return false without creating reconcile entry assert_no_difference "Storage::Entry.where(operation: 'reconcile').count" do result = board.reconcile_storage assert_equal false, result end end test "reconcile_storage returns true on success" do board = @account.boards.create!(name: "Test Board", creator: users(:david)) result = board.reconcile_storage assert_equal true, result end # ensure_storage_total race safety test "ensure_storage_total handles concurrent creation" do @account.storage_total&.destroy threads = 3.times.map do Thread.new do ActiveRecord::Base.connection_pool.with_connection do @account.bytes_used_exact end end end threads.each(&:join) assert_equal 1, Storage::Total.where(owner: @account).count end # per-attachment reconcile test "reconcile counts each attachment separately" do board = @account.boards.create!(name: "Test", creator: users(:david)) # Create 3 distinct blobs (one per card) - no reuse file = file_fixture("moon.jpg") expected_bytes = file.size 3.times do |i| blob = ActiveStorage::Blob.create_and_upload! \ io: file.open, filename: "image_#{i}.jpg", content_type: "image/jpeg" embed = ActionText::Attachment.from_attachable(blob).to_html board.cards.create!(title: "Card #{i}", description: "

    #{embed}

    ", creator: users(:david)) end Storage::Entry.where(board: board).delete_all board.reconcile_storage entry = Storage::Entry.find_by(board: board, operation: "reconcile") # 3 attachments x file_size bytes assert_equal expected_bytes * 3, entry.delta end end ================================================ FILE: test/models/storage/tracked_test.rb ================================================ require "test_helper" class Storage::TrackedTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) @account = accounts("37s") @board1 = boards(:writebook) @board2 = boards(:private) @card = cards(:logo) end test "storage_bytes returns 0 when no attachments" do assert_equal 0, @card.storage_bytes end test "storage_bytes sums all attachment blob sizes" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" assert_equal 1024, @card.storage_bytes end test "storage_bytes includes rich text embeds" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Content with #{embed_html}

    ") assert_equal blob.byte_size, @card.storage_bytes end test "storage_bytes sums direct attachments and rich text embeds" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Content with #{embed_html}

    ") assert_equal 1024 + blob.byte_size, @card.storage_bytes end test "board transfer creates transfer_out entry for old board" do @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" old_board_id = @card.board_id assert_difference "Storage::Entry.count", +2 do @card.update!(board: @board2) end transfer_out = Storage::Entry.find_by(board_id: old_board_id, operation: "transfer_out") assert_not_nil transfer_out assert_equal -2048, transfer_out.delta assert_equal @account.id, transfer_out.account_id assert_equal @card.class.name, transfer_out.recordable_type assert_equal @card.id, transfer_out.recordable_id end test "board transfer creates transfer_in entry for new board" do @card.image.attach io: StringIO.new("x" * 2048), filename: "test.png", content_type: "image/png" @card.update!(board: @board2) transfer_in = Storage::Entry.find_by(board_id: @board2.id, operation: "transfer_in") assert_not_nil transfer_in assert_equal 2048, transfer_in.delta assert_equal @account.id, transfer_in.account_id end test "board transfer does not create entries when no attachments" do # Ensure card has no attachments @card.image.purge if @card.image.attached? # Count only transfer entries initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count @card.update!(board: @board2) final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count assert_equal initial_count, final_count end test "board transfer moves card description embeds" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "card_embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Desc with image #{embed_html}

    ") old_board_id = @card.board_id assert_difference -> { Storage::Entry.where(operation: "transfer_out", recordable: @card).count }, +1 do assert_difference -> { Storage::Entry.where(operation: "transfer_in", recordable: @card).count }, +1 do @card.update!(board: @board2) end end transfer_out = Storage::Entry.where(operation: "transfer_out", recordable: @card).last transfer_in = Storage::Entry.where(operation: "transfer_in", recordable: @card).last assert_equal(-blob.byte_size, transfer_out.delta) assert_equal old_board_id, transfer_out.board_id assert_equal blob.byte_size, transfer_in.delta assert_equal @board2.id, transfer_in.board_id end test "board transfer moves comment embeds" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "comment_embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html comment = @card.comments.create!(body: "

    Comment with image #{embed_html}

    ") old_board_id = @card.board_id assert_difference -> { Storage::Entry.where(operation: "transfer_out", recordable: comment).count }, +1 do assert_difference -> { Storage::Entry.where(operation: "transfer_in", recordable: comment).count }, +1 do @card.update!(board: @board2) end end transfer_out = Storage::Entry.where(operation: "transfer_out", recordable: comment).last transfer_in = Storage::Entry.where(operation: "transfer_in", recordable: comment).last assert_equal(-blob.byte_size, transfer_out.delta) assert_equal old_board_id, transfer_out.board_id assert_equal blob.byte_size, transfer_in.delta assert_equal @board2.id, transfer_in.board_id end test "board transfer moves card image and description embed together" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "card_embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Desc with #{embed_html}

    ") old_board_id = @card.board_id expected_bytes = 1024 + blob.byte_size # One transfer_out and one transfer_in for the card (combined bytes) assert_difference -> { Storage::Entry.where(operation: "transfer_out", recordable: @card).count }, +1 do assert_difference -> { Storage::Entry.where(operation: "transfer_in", recordable: @card).count }, +1 do @card.update!(board: @board2) end end transfer_out = Storage::Entry.where(operation: "transfer_out", recordable: @card).last transfer_in = Storage::Entry.where(operation: "transfer_in", recordable: @card).last assert_equal(-expected_bytes, transfer_out.delta) assert_equal expected_bytes, transfer_in.delta end test "board transfer moves multiple comments with embeds" do blob1 = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed1.jpg", content_type: "image/jpeg" blob2 = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed2.jpg", content_type: "image/jpeg" comment1 = @card.comments.create!(body: "

    #{ActionText::Attachment.from_attachable(blob1).to_html}

    ") comment2 = @card.comments.create!(body: "

    #{ActionText::Attachment.from_attachable(blob2).to_html}

    ") old_board_id = @card.board_id # Should create transfer entries for both comments assert_difference -> { Storage::Entry.where(operation: "transfer_out").count }, +2 do assert_difference -> { Storage::Entry.where(operation: "transfer_in").count }, +2 do @card.update!(board: @board2) end end # Verify each comment's transfer assert_equal(-blob1.byte_size, Storage::Entry.find_by(operation: "transfer_out", recordable: comment1).delta) assert_equal blob1.byte_size, Storage::Entry.find_by(operation: "transfer_in", recordable: comment1).delta assert_equal(-blob2.byte_size, Storage::Entry.find_by(operation: "transfer_out", recordable: comment2).delta) assert_equal blob2.byte_size, Storage::Entry.find_by(operation: "transfer_in", recordable: comment2).delta end test "board transfer net effect on account is zero" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" # Materialize account storage before transfer @account.materialize_storage initial_account_bytes = @account.bytes_used @card.update!(board: @board2) # Materialize again @account.materialize_storage # Account total should be unchanged (transfer_out + transfer_in = 0 for account) assert_equal initial_account_bytes, @account.bytes_used end test "board transfer correctly moves storage between boards" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" # Materialize both boards @board1.materialize_storage @board2.materialize_storage board1_initial = @board1.bytes_used board2_initial = @board2.bytes_used # Small delay to ensure UUIDv7 timestamp advances for transfer entries travel 1.second @card.update!(board: @board2) # Materialize again @board1.materialize_storage @board2.materialize_storage # Board1 loses 1024, Board2 gains 1024 assert_equal board1_initial - 1024, @board1.bytes_used assert_equal board2_initial + 1024, @board2.bytes_used end test "non-board updates do not trigger transfer tracking" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" initial_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count @card.update!(title: "New Title") final_count = Storage::Entry.where(operation: [ "transfer_out", "transfer_in" ]).count assert_equal initial_count, final_count end test "attachments_for_storage returns all direct attachments" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" attachments = @card.send(:attachments_for_storage) assert_equal 1, attachments.count assert_equal @card.image.blob.byte_size, attachments.first.blob.byte_size end test "attachments_for_storage includes rich text embeds" do blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Content with #{embed_html}

    ") attachments = @card.send(:attachments_for_storage) assert_equal 1, attachments.count assert_equal blob.byte_size, attachments.first.blob.byte_size end test "attachments_for_storage includes both direct and rich text attachments" do @card.image.attach io: StringIO.new("x" * 1024), filename: "test.png", content_type: "image/png" blob = ActiveStorage::Blob.create_and_upload! \ io: file_fixture("moon.jpg").open, filename: "embed.jpg", content_type: "image/jpeg" embed_html = ActionText::Attachment.from_attachable(blob).to_html @card.update!(description: "

    Content with #{embed_html}

    ") attachments = @card.send(:attachments_for_storage) assert_equal 2, attachments.count assert_equal 1024 + blob.byte_size, attachments.sum { |a| a.blob.byte_size } end end ================================================ FILE: test/models/tag_test.rb ================================================ require "test_helper" class TagTest < ActiveSupport::TestCase test "downcase title" do assert_equal "a tag", Tag.create!(title: "A TAG").title end test ".unused returns tags not associated with any cards" do unused = Tag.create!(title: "unused") unused_tags = Tag.unused assert_includes unused_tags, unused assert_not_includes unused_tags, tags(:web) assert_not_includes unused_tags, tags(:mobile) end test ".unused returns empty relation if all tags are used" do assert_empty Tag.unused end end ================================================ FILE: test/models/time_window_parser_test.rb ================================================ require "test_helper" class TimeWindowParserTest < ActiveSupport::TestCase setup do @now = Time.zone.parse("2023-06-15 9am") @parser = TimeWindowParser.new(now: @now) end test "parse today" do assert_equal @now.beginning_of_day..@now.end_of_day, @parser.parse("today") end test "parse yesterday" do yesterday = @now - 1.day assert_equal yesterday.beginning_of_day..yesterday.end_of_day, @parser.parse("yesterday") end test "parse this week" do assert_equal @now.beginning_of_week..@now.end_of_week, @parser.parse("this week") end test "parse this month" do assert_equal @now.beginning_of_month..@now.end_of_month, @parser.parse("this month") end test "parse this year" do assert_equal @now.beginning_of_year..@now.end_of_year, @parser.parse("this year") end test "parse last week" do last_week = @now - 1.week assert_equal last_week.beginning_of_week..last_week.end_of_week, @parser.parse("last week") end test "parse last month" do last_month = @now - 1.month assert_equal last_month.beginning_of_month..last_month.end_of_month, @parser.parse("last month") end test "parse last year" do last_year = @now - 1.year assert_equal last_year.beginning_of_year..last_year.end_of_year, @parser.parse("last year") end test "parse with unknown string returns nil" do assert_nil @parser.parse("unknown time window") end test "returns nil for nil" do assert_nil @parser.parse(nil) end end ================================================ FILE: test/models/user/accessor_test.rb ================================================ require "test_helper" class User::AccessorTest < ActiveSupport::TestCase test "new users get added to all_access boards on creation" do user = User.create!(account: accounts("37s"), name: "Jorge") assert_includes user.boards, boards(:writebook) assert_equal user.account.boards.all_access.count, user.boards.count end test "system user does not get added to boards on creation" do system_user = User.create!(account: accounts("37s"), role: "system", name: "Test System User") assert_empty system_user.boards end test "creating a new card draft sets current timestamps" do user = users(:david) board = boards(:writebook) freeze_time do card = user.draft_new_card_in(board) assert card.persisted? assert card.drafted? assert_equal user, card.creator assert_equal board, card.board assert_equal Time.current, card.created_at assert_equal Time.current, card.updated_at assert_equal Time.current, card.last_active_at end end test "reusing an existing card draft refreshes timestamps" do existing_draft = cards(:unfinished_thoughts) user = existing_draft.creator board = existing_draft.board freeze_time do card = user.draft_new_card_in(board) assert_equal existing_draft, card assert_equal Time.current, card.created_at assert_equal Time.current, card.updated_at assert_equal Time.current, card.last_active_at end end end ================================================ FILE: test/models/user/avatar_test.rb ================================================ require "test_helper" class User::AvatarTest < ActiveSupport::TestCase test "avatar_thumbnail returns variant for variable images" do users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg") assert users(:david).avatar.variable? assert_equal users(:david).avatar.variant(:thumb).blob, users(:david).avatar_thumbnail.blob end test "avatar_thumbnail returns original blob for non-variable images" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.svg")), filename: "avatar.svg", content_type: "image/svg+xml") assert_not users(:david).avatar.variable? assert_equal users(:david).avatar.blob, users(:david).avatar_thumbnail.blob end test "allows valid image content types" do users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg", content_type: "image/jpeg") assert users(:david).valid? end test "rejects SVG uploads" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.svg")), filename: "avatar.svg") assert_not users(:david).valid? assert_includes users(:david).errors[:avatar], "must be a JPEG, PNG, GIF, or WebP image" end test "thumb variant is processed immediately on attachment" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") assert users(:david).avatar.variant(:thumb).processed? end test "rejects images that are too wide" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 5000, height: 100 }) assert_not users(:david).valid? assert_includes users(:david).errors[:avatar], "width must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:width]}px" end test "rejects images that are too tall" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 100, height: 5000 }) assert_not users(:david).valid? assert_includes users(:david).errors[:avatar], "height must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:height]}px" end test "accepts images within dimension limits" do users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 4096, height: 4096 }) assert users(:david).valid? end end ================================================ FILE: test/models/user/configurable_test.rb ================================================ require "test_helper" class User::ConfigurableTest < ActiveSupport::TestCase test "should create settings for new users" do user = User.create! account: accounts("37s"), name: "Some new user" assert user.settings.present? end end ================================================ FILE: test/models/user/data_export_test.rb ================================================ require "test_helper" class User::DataExportTest < ActiveSupport::TestCase test "build generates zip with card JSON files" do export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build assert export.completed? assert export.file.attached? assert_equal "application/zip", export.file.content_type end test "build sets status to processing then completed" do export = User::DataExport.create!(account: Current.account, user: users(:david)) export.build assert export.completed? assert_not_nil export.completed_at end test "build sends email when completed" do export = User::DataExport.create!(account: Current.account, user: users(:david)) assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do export.build end end test "build includes only accessible cards for user" do user = users(:david) export = User::DataExport.create!(account: Current.account, user: user) export.build assert export.completed? assert export.file.attached? Tempfile.create([ "test", ".zip" ]) do |temp| temp.binmode export.file.download { |chunk| temp.write(chunk) } temp.rewind reader = ZipKit::FileReader.read_zip_structure(io: temp) json_files = reader.select { |e| e.filename.end_with?(".json") } assert json_files.any?, "Zip should contain at least one JSON file" extractor = json_files.first.extractor_from(temp) json_content = JSON.parse(extractor.extract) assert json_content.key?("number") assert json_content.key?("title") assert json_content.key?("board") assert json_content.key?("creator") assert json_content["creator"].key?("id") assert json_content["creator"].key?("name") assert json_content["creator"].key?("email") assert json_content.key?("description") assert json_content.key?("comments") end end test "build_later enqueues DataExportJob" do export = User::DataExport.create!(account: Current.account, user: users(:david)) assert_enqueued_with(job: DataExportJob, args: [ export ]) do export.build_later end end end ================================================ FILE: test/models/user/email_address_changeable_test.rb ================================================ require "test_helper" class User::EmailAddressChangeableTest < ActiveSupport::TestCase include ActionMailer::TestHelper setup do @identity = identities(:kevin) @user = @identity.users.find_by!(account: accounts("37s")) @new_email = "newart@example.com" @old_email = @identity.email_address end test "send_email_address_change_confirmation" do assert_emails 1 do @user.send_email_address_change_confirmation(@new_email) end end test "change_email_address" do old_identity = @identity new_identity = identities(:mike) assert_difference -> { Identity.count }, +1 do @user.change_email_address(@new_email) end assert_equal @new_email, @user.reload.identity.email_address assert_not old_identity.reload.users.exists?(id: @user.id) assert_equal @new_email, @user.reload.identity.email_address assert_no_difference -> { Identity.count } do @user.change_email_address(new_identity.email_address) end assert_equal new_identity.email_address, @user.reload.identity.email_address end test "change_email_address_using_token" do token = @user.send(:generate_email_address_change_token, to: @new_email) @user.change_email_address_using_token(token) assert_equal @new_email, @user.reload.identity.email_address end test "change_email_address_using_token with invalid token" do assert_not @user.change_email_address_using_token("invalid_token") assert_equal @old_email, @user.reload.identity.email_address token = @user.send(:generate_email_address_change_token, to: @new_email) old_email = "#{SecureRandom.hex(16)}@example.com" @identity.update!(email_address: old_email) @user.reload assert_not @user.change_email_address_using_token(token) assert_equal old_email, @user.reload.identity.email_address end end ================================================ FILE: test/models/user/mentionable_test.rb ================================================ require "test_helper" class User::MentionableTest < ActiveSupport::TestCase test "mentionable handles" do assert_equal [ "dhh", "david", "davidh" ], User.new(name: "David Heinemeier-Hansson").mentionable_handles end test "mentioned by" do users(:david).mentions.destroy_all assert_difference -> { users(:david).mentions.count }, +1 do users(:david).mentioned_by users(:jz), at: cards(:logo) end # No dups assert_no_difference -> { users(:david).mentions.count }, +1 do users(:david).mentioned_by users(:jz), at: cards(:logo) end end end ================================================ FILE: test/models/user/named_test.rb ================================================ require "test_helper" class User::NamedTest < ActiveSupport::TestCase test "initials" do assert_initials "M", name: "Michael" assert_initials "SD", name: "Salvador Dali" assert_initials "LMM", name: "Lin-Manuel Miranda" assert_initials "OCD", name: "O'Conor Díez" assert_initials "ACG", name: "Anne Christine García" assert_initials "ÁL", name: "Ángela López" end test "first name" do assert_first_name "Michael", "Michael" assert_first_name "Salvador", "Salvador Dali" assert_first_name "Lin-Manuel", "Lin-Manuel Miranda" assert_first_name "Anne", "Anne Christine García" end test "last name" do assert_last_name "Dali", "Salvador Dali" assert_last_name "Miranda", "Lin_Manuel Miranda" assert_last_name "Christine García", "Anne Christine García" end private def assert_initials(expected, **attributes) assert_equal expected, User.new(attributes).initials end def assert_first_name(expected, name) assert_equal expected, User.new(name: name).first_name end def assert_last_name(expected, name) assert_equal expected, User.new(name: name).last_name end end ================================================ FILE: test/models/user/notifiable_test.rb ================================================ require "test_helper" class User::NotifiableTest < ActiveSupport::TestCase setup do @user = users(:david) @user.notifications.destroy_all @user.settings.bundle_email_every_few_hours! end test "bundle method creates new bundle for first notification" do notification = assert_difference -> { @user.notification_bundles.count }, 1 do @user.notifications.create!(source: events(:logo_published), creator: @user) end bundle = @user.notification_bundles.last assert_equal notification.updated_at, bundle.starts_at assert bundle.pending? end test "bundle method finds existing bundle within aggregation period" do @user.notifications.create!(source: events(:logo_published), creator: @user) assert_no_difference -> { @user.notification_bundles.count } do @user.notifications.create!(source: events(:layout_published), creator: @user) end end end ================================================ FILE: test/models/user/role_test.rb ================================================ require "test_helper" class User::RoleTest < ActiveSupport::TestCase test "can administer others?" do assert users(:kevin).can_administer?(users(:jz)) assert_not users(:kevin).can_administer?(users(:kevin)) assert_not users(:jz).can_administer?(users(:kevin)) end test "owner can administer admins and members" do assert users(:jason).can_administer?(users(:kevin)) assert users(:jason).can_administer?(users(:david)) assert users(:jason).can_administer?(users(:jz)) end test "owner cannot administer themselves" do assert_not users(:jason).can_administer?(users(:jason)) end test "admin cannot administer the owner" do assert_not users(:kevin).can_administer?(users(:jason)) end test "owner is included in active scope" do active_users = User.active assert_includes active_users, users(:jason) assert_includes active_users, users(:kevin) assert_includes active_users, users(:david) assert_not_includes active_users, users(:system) end test "owner is also considered an admin" do assert users(:jason).owner? assert users(:jason).admin? assert users(:kevin).admin? assert_not users(:kevin).owner? end test "owner scope returns only active owners" do owners = accounts("37s").users.owner assert_includes owners, users(:jason) assert_not_includes owners, users(:kevin) assert_not_includes owners, users(:david) users(:jason).update!(active: false) assert_not_includes accounts("37s").users.owner, users(:jason) end test "admin scope returns active owners and admins" do admins = accounts("37s").users.admin assert_includes admins, users(:jason) assert_includes admins, users(:kevin) assert_not_includes admins, users(:david) users(:kevin).update!(active: false) assert_not_includes accounts("37s").users.admin, users(:kevin) end test "can administer board?" do writebook_board = boards(:writebook) private_board = boards(:private) # Admin can administer any board assert users(:kevin).can_administer_board?(writebook_board) assert users(:kevin).can_administer_board?(private_board) # Creator can administer their own board assert users(:david).can_administer_board?(writebook_board) # Regular user cannot administer boards they didn't create assert_not users(:jz).can_administer_board?(writebook_board) assert_not users(:jz).can_administer_board?(private_board) # Creator cannot administer other people's boards assert_not users(:david).can_administer_board?(private_board) end test "can administer card?" do logo_card = cards(:logo) text_card = cards(:text) # Admin can administer any card assert users(:kevin).can_administer_card?(logo_card) assert users(:kevin).can_administer_card?(text_card) # Creator can administer their own card assert users(:david).can_administer_card?(logo_card) # Regular user cannot administer cards they didn't create assert_not users(:jz).can_administer_card?(logo_card) assert_not users(:jz).can_administer_card?(text_card) # Creator cannot administer other people's cards assert_not users(:david).can_administer_card?(text_card) end end ================================================ FILE: test/models/user/searcher_test.rb ================================================ require "test_helper" class User::SearcherTest < ActiveSupport::TestCase setup do @user = users(:kevin) end test "remember the last search" do assert_difference -> { @user.search_queries.count }, +1 do @user.remember_search("broken") end assert_equal "broken", @user.search_queries.last.terms end test "don't duplicate repeated searches but touch the existing match" do search_result = @user.remember_search("broken") original_updated_at = search_result.updated_at travel_to 1.day.from_now assert_no_difference -> { @user.search_queries.count }, +1 do @user.remember_search("broken") end assert search_result.reload.updated_at > original_updated_at end end ================================================ FILE: test/models/user/settings_test.rb ================================================ require "test_helper" class User::SettingsTest < ActiveSupport::TestCase setup do @user = users(:david) @settings = @user.settings end test "changing the bundle email frequency to never will cancel pending bundles" do @settings.update!(bundle_email_frequency: :every_few_hours) bundle = @user.notification_bundles.create! @settings.update!(bundle_email_frequency: :never) assert_nil Notification::Bundle.find_by(id: bundle.id) end test "changing the bundle email frequency will deliver pending bundles" do bundle = @user.notification_bundles.create! assert bundle.pending? freeze_time Time.current do perform_enqueued_jobs only: Notification::Bundle::DeliverJob do @settings.update!(bundle_email_frequency: :daily) end assert bundle.reload.delivered? assert_equal Time.current, bundle.ends_at end end test "changing other settings will not affect pending bundles" do bundle = @user.notification_bundles.create! perform_enqueued_jobs only: Notification::Bundle::DeliverJob do @settings.update!(updated_at: 1.hour.from_now) end assert bundle.reload.pending? end test "bundling_emails?" do @settings.update!(bundle_email_frequency: :never) assert_not @user.settings.bundling_emails? @settings.update!(bundle_email_frequency: :every_few_hours) assert @user.settings.bundling_emails? @user.update!(role: :system) assert_not @user.settings.bundling_emails?, "System users should not receive bundled emails" @user.update!(role: :member, active: false) assert_not @user.settings.bundling_emails?, "Inactive users should not receive bundled emails" @user.update!(active: true) @user.update_column(:verified_at, nil) assert_not @user.settings.bundling_emails?, "Unverified users should not receive bundled emails" end end ================================================ FILE: test/models/user_test.rb ================================================ require "test_helper" class UserTest < ActiveSupport::TestCase test "create" do user = User.create!( account: accounts("37s"), role: "member", name: "Victor Cooper" ) assert_equal [ boards(:writebook) ], user.boards assert user.settings.present? end test "creation gives access to all_access boards" do user = User.create!( account: accounts("37s"), role: "member", name: "Victor Cooper" ) assert_equal [ boards(:writebook) ], user.boards end test "deactivate" do assert_changes -> { users(:jz).active? }, from: true, to: false do assert_changes -> { users(:jz).accesses.count }, from: 1, to: 0 do users(:jz).tap do |user| user.stubs(:close_remote_connections).once user.deactivate end end end end test "initials" do assert_equal "JF", User.new(name: "jason fried").initials assert_equal "DHH", User.new(name: "David Heinemeier Hansson").initials assert_equal "ÉLH", User.new(name: "Éva-Louise Hernández").initials end test "name methods handle blank names gracefully" do user = User.new(name: "") assert_equal "", user.familiar_name assert_nil user.first_name assert_nil user.last_name assert_equal "", user.initials end test "validates name presence" do user = User.new(account: accounts("37s"), role: "member", name: "") assert_not user.valid? assert_includes user.errors[:name], "can't be blank" user.name = " " assert_not user.valid? assert_includes user.errors[:name], "can't be blank" user.name = "Victor Cooper" assert user.valid? end test "setup?" do user = users(:kevin) user.update!(name: user.identity.email_address) assert_not user.setup? user.update!(name: "Kevin") assert user.setup? end test "verified? returns true when verified_at is present" do user = users(:david) user.update_column(:verified_at, Time.current) assert user.verified? end test "verified? returns false when verified_at is nil" do user = users(:david) user.update_column(:verified_at, nil) assert_not user.verified? end test "verify sets verified_at when not already verified" do user = users(:david) user.update_column(:verified_at, nil) assert_nil user.verified_at user.verify assert_not_nil user.reload.verified_at end test "verify does not update verified_at when already verified" do user = users(:david) original_time = 1.day.ago user.update_column(:verified_at, original_time) user.verify assert_equal original_time.to_i, user.reload.verified_at.to_i end end ================================================ FILE: test/models/webhook/delinquency_tracker_test.rb ================================================ require "test_helper" class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase test "record_delivery_of" do tracker = webhook_delinquency_trackers(:active_webhook_tracker) webhook = tracker.webhook successful_delivery = webhook_deliveries(:successfully_completed) failed_delivery = webhook_deliveries(:errored) tracker.update!(consecutive_failures_count: 5) tracker.record_delivery_of(successful_delivery) tracker.reload assert_equal 0, tracker.consecutive_failures_count assert_nil tracker.first_failure_at assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do tracker.record_delivery_of(failed_delivery) end tracker.reload assert_not_nil tracker.first_failure_at assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do assert_no_difference -> { tracker.reload.first_failure_at } do tracker.record_delivery_of(failed_delivery) end end travel_to 2.hours.from_now do tracker.update!(consecutive_failures_count: 9) webhook.activate assert_changes -> { webhook.reload.active? }, from: true, to: false do tracker.record_delivery_of(failed_delivery) end end end end ================================================ FILE: test/models/webhook/delivery_test.rb ================================================ require "test_helper" class Webhook::DeliveryTest < ActiveSupport::TestCase PUBLIC_TEST_IP = "93.184.216.34" # example.com's real IP, used as a public IP stand-in setup do stub_dns_resolution(PUBLIC_TEST_IP) end test "create" do webhook = webhooks(:active) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) assert_equal "pending", delivery.state end test "succeeded" do webhook = webhooks(:active) event = events(:layout_commented) delivery = Webhook::Delivery.new( webhook: webhook, event: event, response: { code: 200 }, state: :completed ) assert delivery.succeeded? delivery.response[:code] = 422 assert_not delivery.succeeded?, "resonse must have a 2XX status" delivery.response[:code] = 200 delivery.state = :pending assert_not delivery.succeeded?, "state must be completed" delivery.state = :in_progress assert_not delivery.succeeded?, "state must be completed" delivery.state = :errored assert_not delivery.succeeded?, "state must be completed" delivery.state = :completed delivery.response[:error] = :destination_unreachable assert_not delivery.succeeded?, "the response can't have an error" end test "deliver_later" do delivery = webhook_deliveries(:pending) assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do delivery.deliver_later end end test "deliver" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url) .to_return(status: 200, headers: { "content-type" => "application/json" }) assert_equal "pending", delivery.state tracker = delivery.webhook.delinquency_tracker tracker.update!(consecutive_failures_count: 0) assert_no_difference -> { tracker.reload.consecutive_failures_count } do delivery.deliver end assert delivery.persisted? assert_equal "completed", delivery.state assert delivery.request[:headers].present? assert_equal 200, delivery.response[:code] assert delivery.response[:error].blank? assert delivery.succeeded? end test "deliver when the network timeouts" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url).to_timeout tracker = delivery.webhook.delinquency_tracker assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do delivery.deliver end assert_equal "completed", delivery.state assert_equal "connection_timeout", delivery.response[:error] assert_not delivery.succeeded? end test "deliver when the connection is refused" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED) delivery.deliver assert_equal "completed", delivery.state assert_equal "destination_unreachable", delivery.response[:error] end test "deliver when an SSL error occurs" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError) delivery.deliver assert_equal "completed", delivery.state assert_equal "failed_tls", delivery.response[:error] end test "deliver when an unexpected error occurs" do delivery = webhook_deliveries(:pending) stub_request(:post, delivery.webhook.url).to_raise(StandardError, "Unexpected error") assert_raises(StandardError) do delivery.deliver end assert_equal "errored", delivery.state end test "deliver with basecamp webhook format" do webhook = Webhook.create!( board: boards(:writebook), name: "Basecamp", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) request_stub = stub_request(:post, webhook.url) .with do |request| body = CGI.parse(request.body) body.key?("content") && body["content"].first.present? && request.headers["Content-Type"] == "application/x-www-form-urlencoded" end .to_return(status: 200) delivery.deliver assert_requested request_stub assert delivery.succeeded? end test "deliver with campfire webhook format" do webhook = Webhook.create!( board: boards(:writebook), name: "Campfire", url: "https://example.com/rooms/123/456-room-name/messages" ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) request_stub = stub_request(:post, webhook.url) .with do |request| request.body.is_a?(String) && !request.body.start_with?("{") && request.body.present? && request.headers["Content-Type"] == "text/html" end .to_return(status: 200) delivery.deliver assert_requested request_stub assert delivery.succeeded? end test "deliver with slack webhook format" do webhook = Webhook.create!( board: boards(:writebook), name: "Slack", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) request_stub = stub_request(:post, webhook.url) .with do |request| body = JSON.parse(request.body) body.key?("text") && body["text"].present? && request.headers["Content-Type"] == "application/json" end .to_return(status: 200) delivery.deliver assert_requested request_stub assert delivery.succeeded? end test "deliver with generic webhook format" do webhook = Webhook.create!( board: boards(:writebook), name: "Generic", url: "https://example.com/webhook" ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) request_stub = stub_request(:post, webhook.url) .with do |request| body = JSON.parse(request.body) body.present? && !body.key?("line") && !body.key?("text") && request.headers["Content-Type"] == "application/json" end .to_return(status: 200) delivery.deliver assert_requested request_stub assert delivery.succeeded? end test "cleanup" do webhook = webhooks(:active) event = events(:layout_commented) fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event) stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago) Webhook::Delivery.cleanup assert Webhook::Delivery.exists?(fresh_delivery.id) assert_not Webhook::Delivery.exists?(stale_delivery.id) end test "renders the creator name when event creator is current user" do webhook = Webhook.create!( board: boards(:writebook), name: "Basecamp", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" ) event = events(:logo_published) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) Current.session = sessions(:david) request_stub = stub_request(:post, webhook.url) .with { |request| CGI.parse(request.body)["content"].first.include?("David added") } .to_return(status: 200) delivery.deliver assert_requested request_stub end test "basecamp webhook payload html-escapes special characters" do cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) webhook = Webhook.create!( board: boards(:writebook), name: "Basecamp", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" ) delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) captured_body = nil stub_request(:post, webhook.url) .with { |request| captured_body = request.body; true } .to_return(status: 200) delivery.deliver content = CGI.parse(captured_body)["content"].first expected = <<~HTML.strip David added "Tom & Jerry's <Great> "Adventure"" ↗︎ HTML assert_equal expected, content end test "slack webhook payload html-escapes special characters" do cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) webhook = Webhook.create!( board: boards(:writebook), name: "Slack", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow ) delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) captured_body = nil stub_request(:post, webhook.url) .with { |request| captured_body = request.body; true } .to_return(status: 200) delivery.deliver text = JSON.parse(captured_body)["text"] expected = <<~TEXT.strip David added "Tom & Jerry's <Great> "Adventure"" TEXT assert_equal expected, text end test "campfire webhook payload html-escapes special characters" do cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) webhook = Webhook.create!( board: boards(:writebook), name: "Campfire", url: "https://example.com/rooms/123/456-room-name/messages" ) delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) captured_body = nil stub_request(:post, webhook.url) .with { |request| captured_body = request.body; true } .to_return(status: 200) delivery.deliver expected = <<~HTML.strip David added "Tom & Jerry's <Great> "Adventure"" ↗︎ HTML assert_equal expected, captured_body end test "generic webhook payload json-encodes special characters" do cards(:logo).update_column(:title, %(Tom & Jerry's "Adventure")) webhook = Webhook.create!( board: boards(:writebook), name: "Generic", url: "https://example.com/webhook" ) delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published)) captured_body = nil stub_request(:post, webhook.url) .with { |request| captured_body = request.body; true } .to_return(status: 200) delivery.deliver json = JSON.parse(captured_body) assert_equal %(Tom & Jerry's "Adventure"), json["eventable"]["title"] end test "renders creator name when event creator is not current user" do webhook = Webhook.create!( board: boards(:writebook), name: "Basecamp", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines" ) event = events(:logo_published) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) Current.session = sessions(:kevin) request_stub = stub_request(:post, webhook.url) .with { |request| CGI.parse(request.body)["content"].first.include?("David added") } .to_return(status: 200) delivery.deliver assert_requested request_stub end test "blocks DNS rebinding attack where hostname resolves to private IP after validation" do webhook = Webhook.create!( board: boards(:writebook), name: "Rebind Attack", url: "https://rebind.attacker.example/webhook" ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) # Stub DNS to return a private IP (simulating rebind to internal host) stub_dns_resolution("169.254.169.254") # AWS IMDS link-local address delivery.deliver assert_equal "completed", delivery.state assert_equal "private_uri", delivery.response[:error] assert_not delivery.succeeded? end test "connects to the pinned IP address preventing DNS re-resolution" do webhook = Webhook.create!( board: boards(:writebook), name: "Pinned IP", url: "https://example.com/webhook" ) event = events(:layout_commented) delivery = Webhook::Delivery.create!(webhook: webhook, event: event) stub_dns_resolution(PUBLIC_TEST_IP) # Verify Net::HTTP.new is called with the pinned IP response_mock = stub(code: "200") response_mock.stubs(:read_body) http_mock = mock("http") http_mock.stubs(:use_ssl=) http_mock.stubs(:ipaddr=) http_mock.stubs(:open_timeout=) http_mock.stubs(:read_timeout=) http_mock.stubs(:request).yields(response_mock).returns(response_mock) Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock) delivery.deliver assert delivery.succeeded? end test "handles response too large error" do delivery = webhook_deliveries(:pending) large_body = "x" * 200.kilobytes stub_request(:post, delivery.webhook.url).to_return(status: 200, body: large_body) delivery.deliver assert_equal "completed", delivery.state assert_equal "response_too_large", delivery.response[:error] assert_not delivery.succeeded? end test "allows responses within size limit" do delivery = webhook_deliveries(:pending) small_body = "x" * 50.kilobytes stub_request(:post, delivery.webhook.url).to_return(status: 200, body: small_body) delivery.deliver assert_equal "completed", delivery.state assert_equal 200, delivery.response[:code] assert delivery.succeeded? end private def stub_dns_resolution(*ips) dns_mock = mock("dns") dns_mock.stubs(:each_address).multiple_yields(*ips) Resolv::DNS.stubs(:open).yields(dns_mock) end end ================================================ FILE: test/models/webhook/triggerable_test.rb ================================================ require "test_helper" class Webhook::TriggerableTest < ActiveSupport::TestCase setup do @account = accounts(:"37s") @board = boards(:writebook) @card = @board.cards.first @webhook = @board.webhooks.create!( name: "Test Webhook", url: "https://example.com/webhook", subscribed_actions: [ "card_published" ] ) # Create a test event @event = @board.events.create!( creator: users(:david), eventable: @card, action: "card_published" ) @user = users(:david) end test "trigger creates delivery for active accounts" do assert_difference -> { Webhook::Delivery.count }, 1 do @webhook.trigger(@event) end delivery = Webhook::Delivery.last assert_equal @event, delivery.event assert_equal @webhook, delivery.webhook end test "trigger skips cancelled accounts" do @account.cancel(initiated_by: @user) assert_no_difference -> { Webhook::Delivery.count } do @webhook.trigger(@event) end end test "triggered_by scope finds webhooks for event" do other_webhook = @board.webhooks.create!( name: "Other Webhook", url: "https://example.com/other", subscribed_actions: [ "card_closed" ] ) matching_webhooks = Webhook.triggered_by(@event) assert_includes matching_webhooks, @webhook assert_not_includes matching_webhooks, other_webhook end test "active scope only returns active webhooks" do @webhook.update!(active: false) assert_not_includes Webhook.active, @webhook end end ================================================ FILE: test/models/webhook_test.rb ================================================ require "test_helper" class WebhookTest < ActiveSupport::TestCase test "create" do webhook = Webhook.create! name: "Test", url: "https://example.com/webhook", board: boards(:writebook) assert webhook.persisted? assert webhook.active? assert webhook.signing_secret.present? assert webhook.delinquency_tracker.present? end test "validates the url" do webhook = Webhook.new name: "Test", board: boards(:writebook) assert_not webhook.valid? assert_includes webhook.errors[:url], "not a URL" webhook = Webhook.new name: "Test", board: boards(:writebook), url: "not a url" assert_not webhook.valid? assert_includes webhook.errors[:url], "not a URL" webhook = Webhook.new name: "NOTHING", board: boards(:writebook), url: "example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" webhook = Webhook.new name: "BLANK", board: boards(:writebook), url: "//example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" webhook = Webhook.new name: "GOPHER", board: boards(:writebook), url: "gopher://example.com/webhook" assert_not webhook.valid? assert_includes webhook.errors[:url], "must use http or https" webhook = Webhook.new name: "HTTP", board: boards(:writebook), url: "http://example.com/webhook" assert webhook.valid? webhook = Webhook.new name: "HTTPS", board: boards(:writebook), url: "https://example.com/webhook" assert webhook.valid? webhook = Webhook.new name: "TRAILING SPACE", board: boards(:writebook), url: "https://example.com/webhook " assert webhook.valid? assert_equal "https://example.com/webhook", webhook.url end test "deactivate" do webhook = webhooks(:active) assert_changes -> { webhook.active? }, from: true, to: false do webhook.deactivate end end test "activate" do webhook = webhooks(:inactive) assert_changes -> { webhook.active? }, from: false, to: true do webhook.activate end end test "for_slack?" do webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow assert webhook.for_slack? webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678" assert_not webhook.for_slack? webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678" assert_not webhook.for_slack? webhook = Webhook.new url: "https://hooks.slack.com/services/" assert_not webhook.for_slack? webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_slack? end test "for_campfire?" do webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/messages" assert webhook.for_campfire? webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123-test-room/messages" assert webhook.for_campfire? webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123/messages" assert_not webhook.for_campfire?, "The bot key is missing a token" webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_campfire? webhook = Webhook.new url: "https://example.com/rooms/123/messages" assert_not webhook.for_campfire? webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/" assert_not webhook.for_campfire? end test "for_basecamp?" do webhook = Webhook.new url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines" assert webhook.for_basecamp? webhook = Webhook.new url: "https://example.com/webhook" assert_not webhook.for_basecamp? webhook = Webhook.new url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/" assert_not webhook.for_basecamp? webhook = Webhook.new url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines" assert_not webhook.for_basecamp? end end ================================================ FILE: test/models/zip_file_test.rb ================================================ require "test_helper" class ZipFileTest < ActiveSupport::TestCase test "writer adds files with content" do tempfile = Tempfile.new([ "test", ".zip" ]) tempfile.binmode writer = ZipFile::Writer.new(tempfile) writer.add_file("hello.txt", "Hello, World!") writer.close assert writer.exists?("hello.txt") assert_not writer.exists?("missing.txt") end test "writer adds files with block" do tempfile = Tempfile.new([ "test", ".zip" ]) tempfile.binmode writer = ZipFile::Writer.new(tempfile) writer.add_file("hello.txt") { |sink| sink.write("Hello, World!") } writer.close assert writer.exists?("hello.txt") end test "writer globs entries" do tempfile = Tempfile.new([ "test", ".zip" ]) tempfile.binmode writer = ZipFile::Writer.new(tempfile) writer.add_file("docs/readme.txt", "Readme") writer.add_file("docs/guide.txt", "Guide") writer.add_file("images/logo.png", "PNG data") writer.close assert_equal [ "docs/guide.txt", "docs/readme.txt" ], writer.glob("docs/*.txt") assert_equal [ "images/logo.png" ], writer.glob("**/*.png") end test "reader reads file content" do tempfile = create_test_zip("hello.txt" => "Hello, World!") reader = ZipFile::Reader.new(tempfile) content = reader.read("hello.txt") assert_equal "Hello, World!", content end test "reader reads file with block" do tempfile = create_test_zip("hello.txt" => "Hello, World!") reader = ZipFile::Reader.new(tempfile) content = nil reader.read("hello.txt") { |io| content = io.read } assert_equal "Hello, World!", content end test "reader raises for missing file" do tempfile = create_test_zip("hello.txt" => "Hello") reader = ZipFile::Reader.new(tempfile) assert_raises(ArgumentError) { reader.read("missing.txt") } end test "reader checks file existence" do tempfile = create_test_zip("hello.txt" => "Hello") reader = ZipFile::Reader.new(tempfile) assert reader.exists?("hello.txt") assert_not reader.exists?("missing.txt") end test "reader globs entries" do tempfile = create_test_zip( "docs/readme.txt" => "Readme", "docs/guide.txt" => "Guide", "images/logo.png" => "PNG" ) reader = ZipFile::Reader.new(tempfile) assert_equal [ "docs/guide.txt", "docs/readme.txt" ], reader.glob("docs/*.txt") end test "reader io provides size" do tempfile = create_test_zip("hello.txt" => "Hello, World!") reader = ZipFile::Reader.new(tempfile) reader.read("hello.txt") do |io| assert_equal 13, io.size end end test "reader io supports rewind" do tempfile = create_test_zip("hello.txt" => "Hello, World!") reader = ZipFile::Reader.new(tempfile) reader.read("hello.txt") do |io| first_read = io.read io.rewind second_read = io.read assert_equal first_read, second_read end end test "reader io tracks eof" do tempfile = create_test_zip("hello.txt" => "Hello") reader = ZipFile::Reader.new(tempfile) reader.read("hello.txt") do |io| assert_not io.eof? io.read assert io.eof? end end test "reader raises InvalidFileError for non-zip file" do tempfile = Tempfile.new([ "not_a_zip", ".zip" ]) tempfile.write("this is not a zip file at all") tempfile.rewind assert_raises(ZipFile::InvalidFileError) { ZipFile::Reader.new(tempfile) } ensure tempfile&.close tempfile&.unlink end private def create_test_zip(files) tempfile = Tempfile.new([ "test", ".zip" ]) tempfile.binmode writer = ZipFile::Writer.new(tempfile) files.each { |path, content| writer.add_file(path, content) } writer.close tempfile.rewind tempfile end end ================================================ FILE: test/routes_test.rb ================================================ require "test_helper" class RouteTest < ActionDispatch::IntegrationTest test "account/join_code" do assert_recognizes({ controller: "account/join_codes", action: "show" }, "/account/join_code") end test "account/settings" do assert_recognizes({ controller: "account/settings", action: "show" }, "/account/settings") end test "account/entropy" do assert_recognizes({ controller: "account/entropies", action: "show" }, "/account/entropy") end end ================================================ FILE: test/system/.keep ================================================ ================================================ FILE: test/system/back_link_navigation_test.rb ================================================ require "application_system_test_case" class BackLinkNavigationTest < ApplicationSystemTestCase test "card back link returns to board filter view when navigating from it" do sign_in_as(users(:david)) filter_url = board_url(boards(:writebook), creator_ids: [ users(:david).id ]) visit filter_url click_on cards(:logo).title back_link = find("a.btn--back") assert_selector "a.btn--back strong", text: "Back to Writebook" back_link.click assert_current_path filter_url, ignore_query: false end test "card back link returns to global filter view when navigating from it" do sign_in_as(users(:kevin)) filter_url = cards_url(creator_ids: [ users(:kevin).id ]) visit filter_url click_on cards(:text).title assert_selector "a.btn--back strong", text: "Back to all boards" find("a.btn--back").click assert_current_path filter_url, ignore_query: false end test "card back link returns to activity page when navigating from it" do sign_in_as(users(:david)) assert_text "Layout is broken" click_on "Layout is broken" assert_selector "a.btn--back strong", text: "Back to Home" find("a.btn--back").click assert_current_path root_path end test "card back link returns to activity page when navigating from it without trailing slash" do sign_in_as(users(:david)) activity_url_without_trailing_slash = root_url.chomp("/") visit activity_url_without_trailing_slash assert_text "Layout is broken" click_on "Layout is broken" assert_selector "a.btn--back strong", text: "Back to Home" find("a.btn--back").click assert_current_path activity_url_without_trailing_slash end test "card back link is not rewritten when navigating from a non-filter page" do sign_in_as(users(:david)) visit account_settings_url click_on "Invite people" visit card_url(cards(:logo)) assert_selector "a.btn--back strong", text: "Back to Writebook" end end ================================================ FILE: test/system/markdown_paste_test.rb ================================================ require "application_system_test_case" class MarkdownPasteTest < ApplicationSystemTestCase test "markdown paste adds block spacing" do sign_in_as(users(:david)) visit card_url(cards(:layout)) find("lexxy-editor").click paste_markdown("Hello\n\nWorld") within("lexxy-editor") do assert_selector "p", text: "Hello" assert_selector "p br", visible: :all assert_selector "p", text: "World" end end test "markdown paste preserves line breaks" do sign_in_as(users(:david)) visit card_url(cards(:layout)) find("lexxy-editor").click paste_markdown("Hello\nWorld") inner_html = find("lexxy-editor p", text: "Hello").native.property("innerHTML") children = Nokogiri::HTML5.fragment(inner_html).children assert_pattern do children => [ { name: "span", inner_html: "Hello" }, { name: "br" }, { name: "span", inner_html: "World" } ] end end private def paste_markdown(markdown) page.execute_script(<<~JS, markdown) const dt = new DataTransfer(); dt.setData("text/plain", arguments[0]); document.activeElement.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true })); JS end end ================================================ FILE: test/system/smoke_test.rb ================================================ require "application_system_test_case" class SmokeTest < ApplicationSystemTestCase test "joining an account" do account = accounts("37s") visit join_url(code: account.join_code.code, script_name: account.slug) fill_in "Email address", with: "newbie@example.com" click_on "Continue" assert_selector "h1", text: "Check your email" identity = Identity.find_by!(email_address: "newbie@example.com") code = identity.magic_links.active.first.code fill_in "code", with: code send_keys :enter assert_selector "input[id=user_name]" assert account.users.find_by!(identity:).verified?, "User was not properly verified" fill_in "Full name", with: "New Bee" click_on "Continue" assert_selector "h1", text: "Writebook" end test "create a card" do sign_in_as(users(:david)) visit board_url(boards(:writebook)) click_on "Add a card" fill_in "card_title", with: "Hello, world!" fill_in_lexxy with: "I am editing this thing" click_on "Create card" assert_selector "h3", text: "Hello, world!" end test "active storage attachments" do sign_in_as(users(:david)) visit card_url(cards(:layout)) fill_in_lexxy with: "Here is a comment" attach_file file_fixture("moon.jpg") do click_on "Upload file" end within("form lexxy-editor figure.attachment[data-content-type='image/jpeg']") do assert_selector "img[src*='/rails/active_storage']" assert_selector "figcaption textarea[placeholder='moon.jpg']" end click_on "Post" within("action-text-attachment") do assert_selector "a img[src*='/rails/active_storage']" assert_selector "figcaption span.attachment__name", text: "moon.jpg" end # Click the image to open the lightbox find("action-text-attachment figure.attachment a:has(img)").click assert_selector "dialog.lightbox[open]" within("dialog.lightbox") do assert_selector "img.lightbox__image[src*='/rails/active_storage']" end end test "dismissing notifications" do sign_in_as(users(:david)) notification = notifications(:logo_mentioned_david) assert_selector "div##{dom_id(notification)}" within_window(open_new_window) { visit card_url(notification.card) } assert_no_selector "div##{dom_id(notification)}" end test "dragging card to a new column" do sign_in_as(users(:david)) card = Card.find("03axhd1h3qgnsffqplkyf28fv") assert_nil(card.column) visit board_url(boards(:writebook)) card_el = page.find("#article_card_03axhd1h3qgnsffqplkyf28fv") column_el = page.find("#column_03axmcferfmbnv4qg816nw6bg") cards_count = column_el.find(".cards__expander-count").text.to_i card_el.drag_to(column_el) column_el.find(".cards__expander-count", text: cards_count + 1) assert_equal("Triage", card.reload.column.name) end private def fill_in_lexxy(selector = "lexxy-editor", with:) editor_element = find(selector) editor_element.set with page.execute_script("arguments[0].value = '#{with}'", editor_element) end end ================================================ FILE: test/test_helper.rb ================================================ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" require "webmock/minitest" require_relative "webmock_ipaddr_extension" require "vcr" require "mocha/minitest" require "turbo/broadcastable/test_helper" WebMock.allow_net_connect! VCR.configure do |config| config.allow_http_connections_when_no_cassette = true config.cassette_library_dir = "test/vcr_cassettes" config.hook_into :webmock config.filter_sensitive_data("") { Rails.application.credentials.openai_api_key || ENV["OPEN_AI_API_KEY"] } config.default_cassette_options = { match_requests_on: [ :method, :uri, :body ] } # Ignore timestamps in request bodies config.before_record do |i| if i.request&.body i.request.body.gsub!(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "