Merge branch 'main' into mobile/prepare-webviews-2

* main: (26 commits)
  Add a new Pins section to docs/API.md covering pin/unpin and the pinned cards list response.
  Align board name start so it doesn't get too big
  Clean up card/events header layout
  Sync email to Stripe when user changes email address (#2432)
  Fix IDOR in webhook activation endpoint (#2431)
  Add card reactions to API docs and reactions_url to card JSON (#2427)
  Remove unnecessary claude plan
  Allow boosts on cards (#2411)
  Revert "Fix notification click URL by using correct data property"
  Add migration to remove draft cards from search index
  Guard search indexing with searchable? check
  Forbid comments on draft cards
  prefactor: update search to use published cards
  Fix notification click URL by using correct data property
  Wait for service worker to be active before subscribing
  Fix stuck state when permission granted but no subscription
  Extract Card::Commentable
  Include arm64 build in Docker workflow
  Remove unnecessary `await` in push handler
  Correctly initialise WebPush connection (#2417)
  ...
This commit is contained in:
Adrien Maston
2026-01-27 17:17:21 +01:00
92 changed files with 1254 additions and 231 deletions
+7 -2
View File
@@ -34,6 +34,9 @@ jobs:
- 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 }}
@@ -170,11 +173,13 @@ jobs:
docker buildx imagetools create \
--tag "$tag" \
--annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \
"${src_tag}-amd64"
"${src_tag}-amd64" \
"${src_tag}-arm64"
else
docker buildx imagetools create \
--tag "$tag" \
"${src_tag}-amd64"
"${src_tag}-amd64" \
"${src_tag}-arm64"
fi
done <<< "$tags"
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17"><path d="m11.5 10.3-.6-.2c-.2-.5-.3-1.1-.1-1.6 1.1-1.2 1.6-2.7 1.5-4.3 0-2.4-1.6-4.2-3.8-4.2S4.7 1.8 4.7 4.2c-.1 1.5.4 3.1 1.5 4.3.2.5.2 1.1-.1 1.6l-.6.2c-2.4.9-4.2 1.5-4.8 2.6-.4 1.2-.7 2.4-.7 3.6 0 .3.2.5.5.5h16c.3 0 .5-.2.5-.5 0-1.2-.3-2.4-.7-3.5-.6-1.1-2.4-1.8-4.8-2.7"/></svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.5294 14.1176-.7059-.2352c-.2353-.5883-.3529-1.2942-.1176-1.8824 1.2941-1.4118 1.8823-3.17647 1.7647-5.05882 0-2.82353-1.8824-4.94118-4.4706-4.94118-2.58824 0-4.47059 2.11765-4.47059 4.94118-.11765 1.7647.47059 3.64702 1.76471 5.05882.23529.5882.23529 1.2941-.11765 1.8824l-.70588.2352c-2.82353 1.0589-4.94118 1.7648-5.64706 3.0589-.47059 1.4117-.82353 2.8235-.82353 4.2353 0 .3529.23529.5882.58824.5882h18.82356c.3529 0 .5882-.2353.5882-.5882 0-1.4118-.3529-2.8236-.8235-4.1177-.7059-1.2941-2.8236-2.1176-5.6471-3.1765z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 614 B

+29 -6
View File
@@ -536,16 +536,33 @@
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
.card__comments {
--column-gap: 0.8ch;
.card__counts {
--gap: 0.5ch;
align-items: flex-end;
display: flex;
margin-block-end: calc(var(--block-space) * -1.7);
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
flex-shrink: 0;
gap: calc(2 * var(--gap));
margin-inline: auto calc(var(--card-padding-inline) * -0.5);
padding-inline-start: var(--gap);
}
.icon {
--icon-size: 1.6em;
.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);
}
}
@@ -584,11 +601,16 @@
.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;
@@ -599,6 +621,7 @@
&:hover {
.card__content,
.card__footer,
.card__boosts,
.card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 1;
+89 -17
View File
@@ -34,6 +34,23 @@
}
}
@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);
@@ -47,6 +64,8 @@
}
.card__body {
position: relative;
@media (max-width: 639px) {
flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5);
@@ -86,6 +105,11 @@
}
}
.card__meta {
grid-area: meta;
margin-inline-end: auto;
}
.card__stages {
max-inline-size: 32ch;
@@ -119,6 +143,71 @@
}
}
.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) {
font-size: var(--text-x-small);
}
}
.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;
}
}
&:not(:has(.reaction)) {
position: static;
.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;
@@ -132,23 +221,6 @@
inset: calc(var(--bubble-size) / 1.5) 0 auto auto;
}
}
@media (max-width: 799px) {
--half-btn-height: 1.5rem;
--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;
}
}
/* Child items
+6 -2
View File
@@ -62,6 +62,8 @@
}
.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);
@@ -252,9 +254,10 @@
padding-block: var(--block-space-half);
}
/* Meta
/* Footer
/* ------------------------------------------------------------------------ */
/* Card metadata */
.card__meta {
--meta-spacer-block: 0.5ch;
--meta-spacer-inline: 0.75ch;
@@ -376,11 +379,12 @@
display: flex;
flex-direction: column;
font-weight: bold;
inset: auto 1ch 1ch auto;
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;
+5
View File
@@ -80,6 +80,11 @@
display: none !important;
}
}
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
}
}
.comment__author {
+10 -11
View File
@@ -291,6 +291,7 @@
.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;
@@ -300,7 +301,7 @@
max-inline-size: 100%;
min-inline-size: 0;
overflow: clip;
padding: 0.6em;
padding: var(--event-padding);
position: relative;
z-index: 0;
@@ -325,12 +326,15 @@
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;
padding: 0;
translate: 0 -0.3em;
}
.card__board-name {
@@ -377,16 +381,11 @@
}
.event__icon {
color: var(--card-color);
display: grid;
margin: -0.3em -0.3em auto auto;
margin-inline-start: auto;
place-content: center;
.icon {
background-color: var(--card-color);
block-size: 1em;
inline-size: 1em;
grid-area: 1 / 1;
}
translate: calc(var(--event-padding) / 2);
}
.event__timestamp {
+1 -6
View File
@@ -10,8 +10,6 @@
flex-wrap: wrap;
gap: var(--inline-space-half);
inline-size: 100%;
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
&:has([open]) {
z-index: var(--z-popup);
@@ -34,11 +32,8 @@
.reactions__trigger {
--btn-border-color: var(--color-ink-lightest);
background-color: var(--color-ink-lightest);
&:not(:hover) {
opacity: 0.66;
}
background-color: var(--color-ink-lightest);
}
}
}
+1
View File
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
.card__boosts,
.card__comments,
.card__closed {
display: none;
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
include CardScoped
before_action :set_comment
before_action :set_reaction, only: %i[ destroy ]
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
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 = @comment.reactions.create!(params.expect(reaction: :content))
@reaction = @reactable.reactions.create!(params.expect(reaction: :content))
respond_to do |format|
format.turbo_stream
format.turbo_stream { render "reactions/create" }
format.json { head :created }
end
end
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
@reaction.destroy
respond_to do |format|
format.turbo_stream
format.turbo_stream { render "reactions/destroy" }
format.json { head :no_content }
end
end
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
@comment = @card.comments.find(params[:comment_id])
end
def set_reaction
@reaction = @comment.reactions.find(params[:id])
def set_reactable
@reactable = @comment
end
def ensure_permision_to_administer_reaction
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
@@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController
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
@@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController
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
+10 -2
View File
@@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController
@pin = @card.pin_by Current.user
broadcast_add_pin_to_tray
render_pin_button_replacement
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
render_pin_button_replacement
respond_to do |format|
format.turbo_stream { render_pin_button_replacement }
format.json { head :no_content }
end
end
private
@@ -0,0 +1,49 @@
class Cards::ReactionsController < ApplicationController
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 { head :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
+10 -1
View File
@@ -1,6 +1,15 @@
class My::PinsController < ApplicationController
def index
@pins = Current.user.pins.includes(:card).ordered.limit(20)
@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
@@ -1,8 +1,10 @@
class Webhooks::ActivationsController < ApplicationController
include BoardScoped
before_action :ensure_admin
def create
webhook = Current.account.webhooks.find(params[:webhook_id])
webhook = @board.webhooks.find(params[:webhook_id])
webhook.activate
redirect_to webhook
+10
View File
@@ -0,0 +1,10 @@
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
@@ -11,8 +11,7 @@ export default class extends Controller {
switch(Notification.permission) {
case "default":
this.subscribeButtonTarget.hidden = false
this.explainerTarget.hidden = true
this.#showButtonToSubscribe()
break
case "granted":
const registration = await this.#getServiceWorkerRegistration()
@@ -20,6 +19,8 @@ export default class extends Controller {
if (registration && subscription) {
this.element.classList.add(this.enabledClass)
} else {
this.#showButtonToSubscribe()
}
break
}
@@ -54,8 +55,9 @@ export default class extends Controller {
return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
}
#registerServiceWorker() {
return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
async #registerServiceWorker() {
await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
return navigator.serviceWorker.ready
}
async #subscribe(registration) {
@@ -76,6 +78,11 @@ export default class extends Controller {
}
}
#showButtonToSubscribe() {
this.subscribeButtonTarget.hidden = false
this.explainerTarget.hidden = true
}
async #requestPermissionAndSubscribe(registration) {
const permission = await Notification.requestPermission()
if (permission === "granted") this.#subscribe(registration)
+4 -53
View File
@@ -1,13 +1,13 @@
class Card < ApplicationRecord
include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
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 :comments, dependent: :destroy
has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
has_one_attached :image, dependent: :purge_later
has_rich_text :description
@@ -23,7 +23,7 @@ class Card < ApplicationRecord
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, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
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
@@ -66,55 +66,6 @@ class Card < ApplicationRecord
end
private
STORAGE_BATCH_SIZE = 1000
# Override to include comments, but only load comments that have attachments.
# Cards can have thousands of comments; most won't have attachments.
# Streams in batches to avoid loading all IDs into memory at once.
def storage_transfer_records
comment_ids_with_attachments = storage_comment_ids_with_attachments
if comment_ids_with_attachments.any?
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
else
[ self ]
end
end
def storage_comment_ids_with_attachments
direct = []
rich_text_map = {}
# Stream comment IDs in batches to avoid loading all into memory
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
batch_ids = batch.pluck(:id)
direct.concat \
ActiveStorage::Attachment
.where(record_type: "Comment", record_id: batch_ids)
.distinct
.pluck(:record_id)
ActionText::RichText
.where(record_type: "Comment", record_id: batch_ids)
.pluck(:id, :record_id)
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
end
embed_comment_ids = if rich_text_map.any?
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
ActiveStorage::Attachment
.where(record_type: "ActionText::RichText", record_id: batch_ids)
.distinct
.pluck(:record_id)
end.filter_map { |rt_id| rich_text_map[rt_id] }
else
[]
end
(direct + embed_comment_ids).uniq
end
def set_default_title
self.title = "Untitled" if title.blank?
end
+61
View File
@@ -0,0 +1,61 @@
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
+4
View File
@@ -25,4 +25,8 @@ module Card::Searchable
def search_board_id
board_id
end
def searchable?
published?
end
end
+7 -1
View File
@@ -4,10 +4,12 @@ class Comment < ApplicationRecord
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) }, dependent: :delete_all
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 }) }
@@ -22,6 +24,10 @@ class Comment < ApplicationRecord
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
+4
View File
@@ -20,4 +20,8 @@ module Comment::Searchable
def search_board_id
card.board_id
end
def searchable?
card.published?
end
end
+9 -2
View File
@@ -15,11 +15,17 @@ module Searchable
private
def create_in_search_index
search_record_class.create!(search_record_attributes)
if searchable?
search_record_class.create!(search_record_attributes)
end
end
def update_in_search_index
search_record_class.upsert!(search_record_attributes)
if searchable?
search_record_class.upsert!(search_record_attributes)
else
remove_from_search_index
end
end
def remove_from_search_index
@@ -53,4 +59,5 @@ module Searchable
# - 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
+3 -3
View File
@@ -1,6 +1,6 @@
class Reaction < ApplicationRecord
belongs_to :account, default: -> { comment.account }
belongs_to :comment, touch: true
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) }
@@ -11,6 +11,6 @@ class Reaction < ApplicationRecord
private
def register_card_activity
comment.card.touch_last_active_at
reactable.card.touch_last_active_at
end
end
+1
View File
@@ -20,4 +20,5 @@ json.cache! card do
json.has_more_assignees card.assignees.size > 5
json.comments_url card_comments_url(card)
json.reactions_url card_reactions_url(card)
end
+4 -1
View File
@@ -21,11 +21,14 @@
<% if card.published? %>
<%= render "cards/triage/columns", card: card %>
<% end %>
<%= render "cards/display/common/stamp", card: card %>
</div>
<footer class="card__footer full-width flex align-start gap">
<footer class="card__footer">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
<%= render "reactions/reactions", reactable: card %>
</footer>
<% end %>
+1 -1
View File
@@ -36,7 +36,7 @@
<%= comment.body %>
</div>
<%= render "cards/comments/reactions/reactions", comment: comment %>
<%= render "reactions/reactions", reactable: comment %>
</div>
</div>
<% end %>
@@ -1,16 +0,0 @@
<%= turbo_frame_tag comment, :reacting do %>
<div class="reactions">
<div id="<%= dom_id(comment, :reactions) %>" class="reactions__list">
<%= render partial: "cards/comments/reactions/reaction", collection: comment.reactions %>
</div>
<%= turbo_frame_tag comment, :new_reaction do %>
<%= link_to new_card_comment_reaction_path(comment.card, comment), role: "button",
class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open",
data: { turbo_frame: dom_id(comment, :new_reaction), action: "dialog#close" } do %>
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
<span class="for-screen-reader">Add your own reaction</span>
<% end %>
<% end %>
</div>
<% end %>
@@ -1,3 +0,0 @@
<%= turbo_stream.replace([ @comment, :reacting ]) do %>
<%= render "cards/comments/reactions/reactions", comment: @comment.reload %>
<% end %>
@@ -1 +0,0 @@
<%= render "cards/comments/reactions/reactions", comment: @comment %>
@@ -1 +0,0 @@
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
+5 -1
View File
@@ -41,12 +41,16 @@
</div>
<%= render "cards/display/preview/columns", card: card %>
<%= render "cards/display/common/stamp", card: card %>
</div>
</div>
<footer class="card__footer flex gap-half">
<%= render "cards/display/preview/meta", card: card, preview: true %>
<%= render "cards/display/preview/comments", card: card %>
<div class="card__counts">
<%= render "cards/display/preview/boosts", card: card %>
<%= render "cards/display/preview/comments", card: card %>
</div>
<%= render "cards/display/common/background", card: card %>
</footer>
@@ -22,6 +22,7 @@
</div>
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %>
<%= render "cards/display/common/stamp", card: card %>
</div>
</div>
@@ -5,19 +5,3 @@
<%= yield %>
<% end %>
<% if card.postponed? %>
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
<span class="card__closed-title" data-text="Not Now">Not Now</span>
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
</div>
<% end %>
<% if card.closed? %>
<div class="card__closed">
<span class="card__closed-title" data-text="Done">Done</span>
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
</div>
<% end %>
@@ -0,0 +1,15 @@
<% if card.postponed? %>
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
<span class="card__closed-title" data-text="Not Now">Not Now</span>
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
</div>
<% end %>
<% if card.closed? %>
<div class="card__closed">
<span class="card__closed-title" data-text="Done">Done</span>
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
</div>
<% end %>
@@ -1,5 +1,5 @@
<%= render "cards/display/common/background", card: card do %>
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %>
<%= link_to card.image.presence, class: "card__zoom-bg-btn btn", data: { controller: "tooltip", lightbox_target: "image" } do %>
<%= icon_tag "picture-zoom" %>
<span class="for-screen-reader">Zoom background image</span>
<% end %>
@@ -0,0 +1,7 @@
<% boosts = card.reactions %>
<% if boosts.any? %>
<div class="card__boosts">
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
<span><%= boosts.size %></span>
</div>
<% end %>
@@ -1,7 +1,7 @@
<% comments = card.comments.by_user %>
<% if comments.any? %>
<div class="card__comments align-center gap-half flex-item-justify-end flex-item-no-shrink">
<div class="card__comments">
<%= icon_tag "comment" %>
<strong><%= comments.count %></strong>
<span><%= comments.count %></span>
</div>
<% end %>
+1 -1
View File
@@ -18,7 +18,7 @@
</div>
</div>
<footer class="card__footer full-width flex align-start gap">
<footer class="card__footer">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
</footer>
+1 -1
View File
@@ -1,5 +1,5 @@
<% cache event do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%# 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 %>
@@ -1,4 +1,5 @@
<% cache [ day_timeline.events ] do %>
<%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>
<% if day_timeline.has_activity? %>
<div class="events__columns">
<%= render "events/day_timeline/column", column: day_timeline.added_column %>
+3 -5
View File
@@ -7,8 +7,8 @@
related_element_target: "related",
related_element_group_value: card.id,
action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>
<div class="card__header flex align-start gap full-width">
<h4 class="card__board flex-inline align-start">
<div class="card__header">
<h4 class="card__board">
<span class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
@@ -19,9 +19,7 @@
</h4>
<% unless event.action.in?(%w[ card_closed card_published card_reopened ]) %>
<span class="event__icon flex-item-justify-end txt-reversed">
<%= icon_tag event_action_icon(event) %>
</span>
<%= icon_tag event_action_icon(event), class: "event__icon" %>
<% end %>
</div>
<div class="event__content">
+3
View File
@@ -0,0 +1,3 @@
json.array! @pins do |pin|
json.partial! "cards/card", card: pin.card
end
+2 -1
View File
@@ -30,11 +30,12 @@
<div class="card__body justify-space-between">
<%= 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 %>
</div>
<%= render "public/cards/show/steps", card: @card %>
<footer class="card__footer full-width flex align-start gap">
<footer class="card__footer">
<%= render "cards/display/public_preview/meta", card: @card %>
<%= render "cards/display/perma/background", card: @card %>
</footer>
+2 -2
View File
@@ -9,8 +9,8 @@ self.addEventListener('fetch', (event) => {
}
})
self.addEventListener("push", async (event) => {
const data = await event.data.json()
self.addEventListener("push", (event) => {
const data = event.data.json()
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
})
@@ -15,7 +15,7 @@
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 card_comment_reaction_path(reaction.comment.card, reaction.comment, reaction),
<%= 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 %>
@@ -1,5 +1,5 @@
json.cache! reaction do
json.(reaction, :id, :content)
json.reacter reaction.reacter, partial: "users/user", as: :user
json.url card_comment_reaction_url(reaction.comment.card, reaction.comment, reaction)
json.url polymorphic_url([ *reaction_path_prefix_for(reaction.reactable), reaction ])
end
+16
View File
@@ -0,0 +1,16 @@
<%= turbo_frame_tag reactable, :reacting do %>
<div class="reactions">
<div id="<%= dom_id(reactable, :reactions) %>" class="reactions__list">
<%= render partial: "reactions/reaction", collection: reactable.reactions %>
</div>
<%= 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 } %>
<span class="for-screen-reader">Add your own reaction</span>
<% end %>
<% end %>
</div>
<% end %>
@@ -0,0 +1,3 @@
<%= turbo_stream.replace([ @reactable, :reacting ]) do %>
<%= render "reactions/reactions", reactable: @reactable.reload %>
<% end %>
+1
View File
@@ -0,0 +1 @@
<%= render "reactions/reactions", reactable: @reactable %>
+1
View File
@@ -0,0 +1 @@
json.array! @reactable.reactions.ordered, partial: "reactions/reaction", as: :reaction
@@ -1,8 +1,8 @@
<%= turbo_frame_tag @comment, :new_reaction do %>
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
<%= 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(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@reactable, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
<figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag Current.user %>
@@ -12,14 +12,14 @@
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
</label>
<%= render "cards/comments/reactions/menu", comment: @comment %>
<%= render "reactions/menu" %>
<%= form.button class: "reaction__submit-btn btn btn--circle borderless", type: "submit", data: { form_target: "submit" } do %>
<%= icon_tag "check-circle" %> <span class="for-screen-reader">Submit</span>
<% end %>
<%= link_to card_comment_reactions_path(@comment.card, @comment), role: "button",
data: { turbo_frame: dom_id(@comment, :reacting), form_target: "cancel" }, class: "reaction__cancel-btn btn btn--circle borderless" do %>
<%= 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" %> <span class="for-screen-reader">Cancel</span>
<% end %>
<% end %>
+2 -1
View File
@@ -20,7 +20,8 @@ module WebPush::PersistentRequest
endpoint_ip = @options[:endpoint_ip]
if endpoint_ip
http = Net::HTTP.new(uri.host, uri.port, ipaddr: 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?
+2
View File
@@ -86,6 +86,8 @@ Rails.application.routes.draw do
resource :watch
resource :reading
resources :reactions
resources :assignments
resource :self_assignment, only: :create
resources :steps
@@ -0,0 +1,27 @@
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
Generated
+4 -3
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) 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
@@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "reactions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "comment_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 ["comment_id"], name: "index_reactions_on_comment_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
+4 -3
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
ActiveRecord::Schema[8.2].define(version: 2026_01_21_155752) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -412,13 +412,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do
create_table "reactions", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "comment_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 ["comment_id"], name: "index_reactions_on_comment_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
+136 -2
View File
@@ -553,7 +553,8 @@ __Response:__
"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"
"comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments",
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/4/reactions"
},
]
```
@@ -614,6 +615,7 @@ __Response:__
"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",
@@ -803,6 +805,79 @@ __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": "<div class=\"action-text-content\"><p>Hello, World!</p></div>",
"image_url": null,
"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",
"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.
@@ -928,7 +1003,66 @@ __Response:__
Returns `204 No Content` on success.
## Reactions
## 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.
@@ -0,0 +1,8 @@
class Account::SyncStripeCustomerEmailJob < ApplicationJob
queue_as :default
retry_on Stripe::StripeError, wait: :polynomially_longer
def perform(subscription)
subscription.sync_customer_email_to_stripe
end
end
+4
View File
@@ -31,6 +31,10 @@ module Account::Billing
reload_billing_waiver
end
def owner_email_changed
Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription
end
private
def active_subscription
if comped?
+17
View File
@@ -47,4 +47,21 @@ class Account::Subscription < SaasRecord
# Subscription already deleted/canceled in Stripe - treat as success
Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}"
end
def sync_customer_email_to_stripe
if stripe_customer_id && (email = owner_email)
Stripe::Customer.update(stripe_customer_id, email: email)
end
rescue Stripe::InvalidRequestError => e
# Customer already deleted in Stripe - treat as success
Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}"
end
private
# Account owner email for Stripe customer record. Returns nil when:
# - No owner exists (ownership being transferred, account in limbo)
# - Owner has no identity (deactivated user)
def owner_email
account.users.owner.first&.identity&.email_address
end
end
@@ -0,0 +1,23 @@
module User::NotifiesAccountOfEmailChange
extend ActiveSupport::Concern
included do
after_update :notify_account_of_owner_change, if: :account_owner_changed?
end
private
# Account owner changed when:
# - The current owner changed their email
# - A user just became the owner (ownership transfer)
def account_owner_changed?
owner? && identity && (saved_change_to_identity_id? || became_owner?)
end
def became_owner?
saved_change_to_role? && role_before_last_save != "owner"
end
def notify_account_of_owner_change
account.owner_email_changed
end
end
+1
View File
@@ -133,6 +133,7 @@ module Fizzy
config.to_prepare do
::Account.include Account::Billing, Account::Limited
::User.include User::NotifiesAccountOfEmailChange
::Signup.prepend Fizzy::Saas::Signup
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
+22
View File
@@ -68,4 +68,26 @@ class Account::BillingTest < ActiveSupport::TestCase
account.incinerate
end
test "owner_email_changed enqueues sync job when subscription exists" do
account = accounts(:"37s")
account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
account.owner_email_changed
end
end
test "owner_email_changed does nothing without subscription" do
account = accounts(:initech)
account.subscription&.destroy
assert_no_enqueued_jobs do
account.owner_email_changed
end
end
end
@@ -117,4 +117,78 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
subscription.cancel
end
end
test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
account = accounts(:"37s")
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
account = accounts(:"37s")
subscription = account.build_subscription(
stripe_customer_id: nil,
plan_key: "free_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing without owner" do
account = accounts(:"37s")
account.users.update_all(role: :member)
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe does nothing when owner has no identity" do
account = accounts(:"37s")
owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
owner.update_column(:identity_id, nil)
subscription = account.create_subscription!(
stripe_customer_id: "cus_test",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.expects(:update).never
subscription.sync_customer_email_to_stripe
end
test "sync_customer_email_to_stripe treats deleted customer as success" do
account = accounts(:"37s")
account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
subscription = account.create_subscription!(
stripe_customer_id: "cus_deleted",
plan_key: "monthly_v1",
status: "active"
)
Stripe::Customer.stubs(:update).raises(
Stripe::InvalidRequestError.new("No such customer", {})
)
assert_nothing_raised do
subscription.sync_customer_email_to_stripe
end
end
end
@@ -0,0 +1,51 @@
require "test_helper"
class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
@owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
@member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
name: "Member",
identity: Identity.create!(email_address: "member@example.com"),
role: :member
)
end
test "notifies account when owner changes email" do
@account.expects(:owner_email_changed).once
new_identity = Identity.create!(email_address: "new-owner@example.com")
@owner.update!(identity: new_identity)
end
test "does not notify account when non-owner changes email" do
@account.expects(:owner_email_changed).never
new_identity = Identity.create!(email_address: "new-member@example.com")
@member.update!(identity: new_identity)
end
test "does not notify account when owner is deactivated" do
@account.expects(:owner_email_changed).never
@owner.update!(identity: nil)
end
test "does not notify account when identity unchanged" do
@account.expects(:owner_email_changed).never
@owner.update!(name: "New Name")
end
test "notifies account when user becomes owner" do
@account.expects(:owner_email_changed).once
@member.update!(role: :owner)
end
test "does not notify account when owner becomes member" do
@account.expects(:owner_email_changed).never
@owner.update!(role: :member)
end
end
@@ -0,0 +1,22 @@
#!/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}"
@@ -13,6 +13,16 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
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
@@ -69,6 +79,8 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
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.id), @response.parsed_body.dig("card", "url")
assert_equal card_comment_reactions_url(comment.card_id, comment.id), @response.parsed_body["reactions_url"]
assert_equal card_comment_url(comment.card_id, comment.id), @response.parsed_body["url"]
end
test "update as JSON" do
@@ -17,6 +17,17 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
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
@@ -28,4 +39,15 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest
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
@@ -0,0 +1,67 @@
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
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
@@ -161,6 +161,8 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_equal card.title, @response.parsed_body["title"]
assert_equal card.closed?, @response.parsed_body["closed"]
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
@@ -11,4 +11,14 @@ class My::PinsControllerTest < ActionDispatch::IntegrationTest
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
+5 -4
View File
@@ -5,10 +5,10 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
setup do
@board.update!(all_access: true)
@card = @board.cards.create!(title: "Layout is broken", description: "Look at this mess.", creator: @user)
@comment_card = @board.cards.create!(title: "Some card", creator: @user)
@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", 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 }
@@ -43,7 +43,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
end
test "search highlights matched terms with proper HTML marks" do
@board.cards.create!(title: "Testing search highlighting", creator: @user)
@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
@@ -52,6 +52,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
test "search preserves highlight marks but escapes surrounding HTML" do
@board.cards.create!(
title: "<b>Bold</b> testing content",
status: "published",
creator: @user
)
@@ -16,4 +16,20 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
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
end
@@ -121,4 +121,13 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
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
end
+16 -2
View File
@@ -2,12 +2,26 @@ kevin:
id: <%= ActiveRecord::FixtureSet.identify("kevin_reaction", :uuid) %>
account: 37s_uuid
content: "👍"
comment: logo_agreement_jz_uuid
reactable: logo_agreement_jz_uuid (Comment)
reacter: kevin_uuid
david:
id: <%= ActiveRecord::FixtureSet.identify("david_reaction", :uuid) %>
account: 37s_uuid
content: "👍"
comment: logo_agreement_jz_uuid
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
@@ -0,0 +1,31 @@
require "test_helper"
class CardPreviewBoostCountTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
@card_with_reactions = cards(:logo)
@card_without_reactions = cards(:layout)
@column = @card_with_reactions.column
end
test "card preview displays boost count when card has reactions" do
get board_column_path(@column.board, @column)
assert_response :success
# Check that boost count is displayed for cards with reactions
assert_select ".card__boosts", text: /2/
end
test "card preview does not display boost count when card has no reactions" do
# Ensure layout card is in the same column for this test
@card_without_reactions.update!(column: @column)
get board_column_path(@column.board, @column)
assert_response :success
# Find the card without reactions and verify no boost count is shown
# We check the overall page doesn't have a boost count for zero reactions
# (This is an imperfect test but reasonable given the structure)
assert @card_without_reactions.reactions.none?
end
end
@@ -0,0 +1,26 @@
require "test_helper"
class WebPush::PersistentRequestTest < ActiveSupport::TestCase
PUBLIC_TEST_IP = "142.250.185.206"
ENDPOINT = "https://fcm.googleapis.com/fcm/send/test123"
test "pins connection to endpoint_ip" do
request = stub_request(:post, ENDPOINT)
.with(ipaddr: PUBLIC_TEST_IP)
.to_return(status: 201)
notification = WebPush::Notification.new(
title: "Test",
body: "Test notification",
path: "/test",
badge: 0,
endpoint: ENDPOINT,
endpoint_ip: PUBLIC_TEST_IP,
p256dh_key: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
auth_key: "tBHItJI5svbpez7KI4CCXg"
)
notification.deliver
assert_requested request
end
end
+17
View File
@@ -1,6 +1,18 @@
require "test_helper"
class Card::CommentableTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "capturing comments" do
assert_difference -> { cards(:logo).comments.count }, +1 do
cards(:logo).comments.create!(body: "Agreed.")
end
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
end
test "creating a comment on a card makes the creator watch the card" do
boards(:writebook).access_for(users(:kevin)).access_only!
assert_not cards(:text).watched_by?(users(:kevin))
@@ -11,4 +23,9 @@ class Card::CommentableTest < ActiveSupport::TestCase
assert cards(:text).watched_by?(users(:kevin))
end
test "commentable is true for published cards, false for drafts" do
assert cards(:logo).commentable?
assert_not cards(:unfinished_thoughts).commentable?
end
end
+59 -7
View File
@@ -3,20 +3,30 @@ require "test_helper"
class Card::SearchableTest < ActiveSupport::TestCase
include SearchTestHelper
test "searchable? returns true for published cards" do
card = @board.cards.create!(title: "Published Card", status: "published", creator: @user)
assert card.searchable?
end
test "searchable? returns false for draft cards" do
card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user)
assert_not card.searchable?
end
test "card search" do
# Searching by title
card = @board.cards.create!(title: "layout is broken", creator: @user)
card = @board.cards.create!(title: "layout is broken", status: "published", creator: @user)
results = Card.mentioning("layout", user: @user)
assert_includes results, card
# Searching by comment
card_with_comment = @board.cards.create!(title: "Some card", creator: @user)
card_with_comment = @board.cards.create!(title: "Some card", status: "published", creator: @user)
card_with_comment.comments.create!(body: "overflowing text", creator: @user)
results = Card.mentioning("overflowing", user: @user)
assert_includes results, card_with_comment
# Sanitizing search query
card_broken = @board.cards.create!(title: "broken layout", creator: @user)
card_broken = @board.cards.create!(title: "broken layout", status: "published", creator: @user)
results = Card.mentioning("broken \"", user: @user)
assert_includes results, card_broken
@@ -25,8 +35,8 @@ class Card::SearchableTest < ActiveSupport::TestCase
# Filtering by board_ids
other_board = Board.create!(name: "Other Board", account: @account, creator: @user)
card_in_board = @board.cards.create!(title: "searchable content", creator: @user)
card_in_other_board = other_board.cards.create!(title: "searchable content", creator: @user)
card_in_board = @board.cards.create!(title: "searchable content", status: "published", creator: @user)
card_in_other_board = other_board.cards.create!(title: "searchable content", status: "published", creator: @user)
results = Card.mentioning("searchable", user: @user)
assert_includes results, card_in_board
assert_not_includes results, card_in_other_board
@@ -37,7 +47,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
# Create a card with unreasonably long content
long_content = "asdf " * Searchable::SEARCH_CONTENT_LIMIT
card = @board.cards.create!(title: "Card with long description", creator: @user)
card = @board.cards.create!(title: "Card with long description", status: "published", creator: @user)
card.description = ActionText::Content.new(long_content)
card.save!
@@ -52,7 +62,7 @@ class Card::SearchableTest < ActiveSupport::TestCase
test "deleting card removes search record and FTS entry" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Card to delete", creator: @user)
card = @board.cards.create!(title: "Card to delete", status: "published", creator: @user)
# Verify search record exists
search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
@@ -78,4 +88,46 @@ class Card::SearchableTest < ActiveSupport::TestCase
assert_equal 0, fts_count, "FTS entry should be deleted"
end
end
test "updating a draft card does not index it" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Draft card", creator: @user, status: "drafted")
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
card.update!(title: "Updated draft card")
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id),
"Draft card should not be indexed after update"
results = Card.mentioning("Updated", user: @user)
assert_not_includes results, card
end
test "publishing a draft card indexes it" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "drafted")
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
card.publish
search_record = search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
assert_not_nil search_record, "Published card should be indexed"
assert_equal card.id, search_record.card_id
results = Card.mentioning("publish", user: @user)
assert_includes results, card
end
test "unpublishing a draft card removes it from the search index" do
search_record_class = Search::Record.for(@user.account_id)
card = @board.cards.create!(title: "Draft to publish", creator: @user, status: "published")
assert_not_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
card.update!(status: "drafted")
assert_nil search_record_class.find_by(searchable_type: "Card", searchable_id: card.id)
results = Card.mentioning("publish", user: @user)
assert_not_includes results, card
end
end
+14 -8
View File
@@ -18,14 +18,6 @@ class CardTest < ActiveSupport::TestCase
assert_equal account.reload.cards_count, card.number
end
test "capturing messages" do
assert_difference -> { cards(:logo).comments.count }, +1 do
cards(:logo).comments.create!(body: "Agreed.")
end
assert_equal "Agreed.", cards(:logo).comments.last.body.to_plain_text.chomp
end
test "assignment states" do
assert cards(:logo).assigned_to?(users(:kevin))
assert_not cards(:logo).assigned_to?(users(:david))
@@ -207,4 +199,18 @@ class CardTest < ActiveSupport::TestCase
assert card.watched_by?(kevin), "Kevin's watch should remain (has board access)"
assert_not card.watched_by?(david), "David's watch should be deleted (no board access)"
end
test "card has reactions association" do
card = cards(:logo)
user = users(:david)
assert_difference "card.reactions.count", +1 do
card.reactions.create!(content: "👍", reacter: user)
end
reaction = card.reactions.last
assert_equal "👍", reaction.content
assert_equal user, reaction.reacter
assert_equal card, reaction.reactable
end
end
+14 -3
View File
@@ -4,7 +4,18 @@ class Comment::SearchableTest < ActiveSupport::TestCase
include SearchTestHelper
setup do
@card = @board.cards.create!(title: "Test Card", creator: @user)
@card = @board.cards.create!(title: "Test Card", status: "published", creator: @user)
end
test "searchable? returns true for comments on published cards" do
comment = @card.comments.create!(body: "test comment", creator: @user)
assert comment.searchable?
end
test "searchable? returns false for comments on draft cards" do
draft_card = @board.cards.create!(title: "Draft Card", status: "drafted", creator: @user)
comment = draft_card.comments.build(body: "test comment", creator: @user)
assert_not comment.searchable?
end
test "comment search" do
@@ -40,9 +51,9 @@ class Comment::SearchableTest < ActiveSupport::TestCase
end
# Finding cards via comment search
card_with_comment = @board.cards.create!(title: "Card One", creator: @user)
card_with_comment = @board.cards.create!(title: "Card One", status: "published", creator: @user)
card_with_comment.comments.create!(body: "unique searchable phrase", creator: @user)
card_without_comment = @board.cards.create!(title: "Card Two", creator: @user)
card_without_comment = @board.cards.create!(title: "Card Two", status: "published", creator: @user)
results = Card.mentioning("searchable", user: @user)
assert_includes results, card_with_comment
assert_not_includes results, card_without_comment
+13
View File
@@ -5,6 +5,19 @@ class CommentTest < ActiveSupport::TestCase
Current.session = sessions(:david)
end
test "cannot create comment on a draft card" do
draft_card = cards(:unfinished_thoughts)
comment = draft_card.comments.build(body: "This should fail")
assert_not comment.valid?
assert_includes comment.errors[:card], "does not allow comments"
assert_raises(ActiveRecord::RecordInvalid) do
draft_card.comments.create!(body: "This should raise")
end
end
test "rich text embed variants are processed immediately on attachment" do
comment = cards(:logo).comments.create!(body: "Check this out")
comment.body.body.attachables # force load
-10
View File
@@ -70,16 +70,6 @@ class MentionsTest < ActiveSupport::TestCase
end
end
test "don't create mentions from comments when belonging to unpublished cards" do
perform_enqueued_jobs only: Mention::CreateJob do
card = boards(:writebook).cards.create title: "Cleanup", description: "Some initial content"
assert_no_difference -> { Mention.count } do
card.comments.create!(body: "Great work on this #{mention_html_for(users(:david))}!")
end
end
end
test "can't mention users that don't have access to the board" do
boards(:writebook).update! all_access: false
boards(:writebook).accesses.revoke_from(users(:david))
+1 -2
View File
@@ -4,8 +4,7 @@ class Filter::SearchTest < ActiveSupport::TestCase
include SearchTestHelper
test "deduplicate multiple results" do
card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user)
card.published!
card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user, status: "published")
card.comments.create(body: "I hate haggis.", creator: @user)
card.comments.create(body: "I love haggis.", creator: @user)
+32 -1
View File
@@ -5,9 +5,40 @@ class ReactionTest < ActiveSupport::TestCase
Current.session = sessions(:david)
end
test "creating a reaction touches the card activity" do
test "creating a comment reaction touches the card activity" do
assert_changes -> { cards(:logo).reload.last_active_at } do
comments(:logo_1).reactions.create!(content: "Nice!")
end
end
test "reactions are deleted when comment is destroyed" do
comment = comments(:logo_1)
comment.reactions.create!(content: "👍")
reaction_ids = comment.reactions.pluck(:id)
assert reaction_ids.any?, "Expected comment to have reactions"
comment.destroy
assert_empty Reaction.where(id: reaction_ids)
end
test "creating a card reaction touches the card activity" do
card = cards(:logo)
assert_changes -> { card.reload.last_active_at } do
card.reactions.create!(content: "🎉")
end
end
test "reactions are deleted when card is destroyed" do
card = cards(:logo)
reaction_ids = card.reactions.pluck(:id)
assert reaction_ids.any?, "Expected card to have reactions"
card.destroy
assert_empty Reaction.where(id: reaction_ids)
end
end
+9 -4
View File
@@ -5,8 +5,8 @@ class SearchTest < ActiveSupport::TestCase
test "search" do
# Search cards and comments
card = @board.cards.create!(title: "layout design", creator: @user)
comment_card = @board.cards.create!(title: "Some card", creator: @user)
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)
@@ -15,11 +15,16 @@ class SearchTest < ActiveSupport::TestCase
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)
inaccessible_card = inaccessible_board.cards.create!(title: "searchable content", 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 }
+1
View File
@@ -3,6 +3,7 @@ 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"
+1
View File
@@ -17,6 +17,7 @@ module SearchTestHelper
Current.account = @account
@identity = Identity.create!(email_address: "test@example.com")
@user = User.create!(name: "Test User", account: @account, identity: @identity)
Current.user = @user
@board = Board.create!(name: "Test Board", account: @account, creator: @user)
end
+44
View File
@@ -0,0 +1,44 @@
# Extends WebMock to support ipaddr matching for testing IP pinning.
#
# Usage:
# stub_request(:post, "https://example.com/push")
# .with(ipaddr: "93.184.216.34")
# .to_return(status: 201)
#
# If the HTTP connection's ipaddr doesn't match, the stub won't match and
# WebMock will raise an error about an unregistered request.
module WebMock
class RequestSignature
attr_accessor :ipaddr
end
module RequestPatternIpaddrExtension
attr_accessor :ipaddr_pattern
def assign_options(options)
options = options.dup
@ipaddr_pattern = options.delete(:ipaddr) || options.delete("ipaddr")
super(options)
end
def matches?(request_signature)
super && ipaddr_matches?(request_signature)
end
private
def ipaddr_matches?(request_signature)
@ipaddr_pattern.nil? || @ipaddr_pattern == request_signature.ipaddr
end
end
RequestPattern.prepend RequestPatternIpaddrExtension
module NetHTTPUtilityIpaddrExtension
def request_signature_from_request(net_http, request, body = nil)
super.tap { |signature| signature.ipaddr = net_http.ipaddr }
end
end
NetHTTPUtility.singleton_class.prepend NetHTTPUtilityIpaddrExtension
end