Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/discourse-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ on:
branches:
- main
pull_request:
# Manual trigger so we can run the full Discourse plugin matrix
# (linting, backend_tests, system_tests, annotations_tests) against
# an arbitrary branch on this fork without needing an upstream PR
# (which is gated on a manual workflow approval for fork
# contributors). `gh workflow run "Discourse Plugin" --ref <branch>`.
workflow_dispatch:

jobs:
ci:
uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
# Force lowercase plugin directory. Discourse derives the compiled
# stylesheet bundle slug from the on-disk dir, and the route that
# serves /stylesheets/<slug>_<hash>.css is constrained to
# `[-a-z0-9_]+`. The default (`github.event.repository.name`)
# evaluates to "JtechTools" (uppercase) which broke every CSS
# request → "Refused to apply style" → unstyled plugin everywhere.
# Same fix b284c8d applied for local dev; missing here is why the
# SCSS bug came back in this PR's system_tests.
with:
name: jtech-tools
192 changes: 192 additions & 0 deletions app/controllers/discourse_mod_categories/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,155 @@ def delete_note
render json: note_thread_json(topic)
end

# Toggles or updates a post's whisper state (and audience) after the
# post already exists. Discourse's PostsController#update path drops
# whisper params (`add_permitted_post_create_param` is create-only and
# there's no `serializeOnUpdate` for these fields), so editing a post
# in the composer and saving doesn't propagate whisper toggles.
# The frontend calls this endpoint as a follow-up to any staff edit
# where the whisper state was changed.
#
# Staff-only: non-staff users get 403, including the post's own
# author. A user who didn't have permission to arm a whisper on
# create shouldn't be able to add or remove one on edit.
def update_post_whisper
raise Discourse::NotFound unless SiteSetting.mod_whisper_enabled

post = ::Post.find_by(id: params[:id])
raise Discourse::NotFound unless post
raise Discourse::InvalidAccess.new("staff_only") unless current_user.staff?
raise Discourse::InvalidAccess.new("cannot_edit") unless guardian.can_edit?(post)

armed = ActiveModel::Type::Boolean.new.cast(params[:mod_whisper])

targets_field = DiscourseModCategories::POST_WHISPER_TARGETS_FIELD
groups_field = DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD
badges_field = DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD
participants_field = DiscourseModCategories::TOPIC_WHISPER_PARTICIPANTS_FIELD

if armed
user_ids = sanitize_ids(params[:mod_whisper_target_user_ids])
group_ids = sanitize_ids(params[:mod_whisper_target_group_ids])
badge_ids = sanitize_ids(params[:mod_whisper_target_badge_ids])

# Validate IDs against the DB so a typo / stale ID doesn't end up
# in the custom_fields. on(:post_created) does the same shape.
user_ids = ::User.where(id: user_ids).pluck(:id) if user_ids.any?
group_ids = ::Group.where(id: group_ids).pluck(:id) if group_ids.any?
badge_ids = ::Badge.where(id: badge_ids, enabled: true).pluck(:id) if badge_ids.any?

post.custom_fields[targets_field] = user_ids
post.custom_fields[groups_field] = group_ids
post.custom_fields[badges_field] = badge_ids
post.save_custom_fields(true)

# Cumulative topic-participants update — mirrors what
# on(:post_created) does so a freshly-targeted user starts seeing
# ALL whispers in the topic, not just future ones.
if post.topic
existing = Array(post.topic.custom_fields[participants_field]).map(&:to_i)
additions = user_ids.dup
additions += ::GroupUser.where(group_id: group_ids).pluck(:user_id) if group_ids.any?
additions += ::UserBadge.where(badge_id: badge_ids).pluck(:user_id) if badge_ids.any?
merged = (existing + additions).uniq
if merged.sort != existing.sort
post.topic.custom_fields[participants_field] = merged
post.topic.save_custom_fields(true)
end
end
else
# Disarming: the `mod_is_whisper` serializer keys off
# `custom_fields.key?(targets_field)`, so an empty array is NOT
# enough — the rows have to be physically removed.
::PostCustomField.where(
post_id: post.id,
name: [targets_field, groups_field, badges_field],
).destroy_all
post.reload
end

render json: serialized_post_whisper_state(post.reload)
end

# Records the current staff user as a viewer of the mod-note panel on
# the given topic. Idempotent — re-viewing updates `viewed_at` on the
# existing entry rather than appending a duplicate row. The returned
# `viewers` array drives the "👁 Viewed by N" pill at the bottom of
# the panel, refreshed inline without a topic reload.
def record_note_view
topic = Topic.find_by(id: params[:topic_id])
raise Discourse::NotFound unless topic

guardian.ensure_can_manage_mod_messages!

# No-op if there's no note to view — keeps stray refresh-on-mount
# pings from creating viewer rows on topics that never had a note.
note = topic.custom_fields[TOPIC_PRIVATE_NOTE_FIELD].to_s
raise Discourse::NotFound if note.strip.empty?

now = Time.zone.now.iso8601
raw = topic.custom_fields[DiscourseModCategories::TOPIC_NOTE_VIEWERS_FIELD]
viewers = raw.is_a?(Array) ? raw.deep_dup : []

existing = viewers.find { |v| v["user_id"].to_i == current_user.id }
if existing
existing["viewed_at"] = now
# Refresh denormalized identity fields in case the user renamed /
# changed their avatar since their last view.
existing["username"] = current_user.username
existing["name"] = current_user.name
existing["avatar_template"] = current_user.avatar_template
else
viewers << {
"user_id" => current_user.id,
"username" => current_user.username,
"name" => current_user.name,
"avatar_template" => current_user.avatar_template,
"viewed_at" => now,
}
end

topic.custom_fields[DiscourseModCategories::TOPIC_NOTE_VIEWERS_FIELD] = viewers
topic.save_custom_fields(true)

render json: { viewers: serialized_note_viewers(viewers) }
end

# Marks the current user's custom mod-note + whisper notifications for
# the given topic as read. Called by the frontend whenever the user
# navigates to a topic page — Discourse's built-in auto-mark-read only
# covers a hardcoded list of notification types and skips
# `Notification.types[:custom]`, so plugin notifications about a topic
# would sit unread in the bell forever even after the user opened the
# topic. The data-column LIKE filter pins this to our notifications
# only (mod_note, mod_whisper, and the legacy whisper_notification
# message key) so unrelated custom notifications another plugin might
# attach to the same topic are left alone.
def mark_topic_notifications_seen
topic = Topic.find_by(id: params[:topic_id])
raise Discourse::NotFound unless topic

marked =
::Notification
.where(
user_id: current_user.id,
topic_id: topic.id,
notification_type: ::Notification.types[:custom],
read: false,
)
.where(
"data LIKE ? OR data LIKE ? OR data LIKE ?",
'%"mod_note":true%',
'%"mod_whisper":true%',
'%"discourse_mod_categories.whisper.whisper_notification"%',
)
.update_all(read: true)

current_user.publish_notifications_state if marked > 0

render json: { marked: marked }
end

# Lists recent moderator notes across topics, for the staff user-menu
# tab, newest first.
def notes_feed
Expand Down Expand Up @@ -555,5 +704,48 @@ def serialized_note_replies(topic)
}
end
end

# Strips/dedupes ID arrays sent by the composer for whisper target
# update. Casts to ints, drops zero/nil, dedupes. Both endpoints
# (update_post_whisper) and the on(:post_created) hook share this
# shape so they normalize identically.
def sanitize_ids(raw)
Array(raw).map(&:to_i).reject(&:zero?).uniq
end

# Mirrors the post serializer's whisper fields so the frontend can
# swap the response in for the post's local state without a topic
# reload. The four ids* fields match the existing :post serializer
# overrides in sub_plugins/mod_categories.rb.
def serialized_post_whisper_state(post)
targets_field = DiscourseModCategories::POST_WHISPER_TARGETS_FIELD
{
mod_is_whisper: post.custom_fields.key?(targets_field),
mod_whisper_target_user_ids: Array(post.custom_fields[targets_field]).map(&:to_i),
mod_whisper_target_group_ids:
Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_GROUPS_FIELD]).map(
&:to_i
),
mod_whisper_target_badge_ids:
Array(post.custom_fields[DiscourseModCategories::POST_WHISPER_TARGET_BADGES_FIELD]).map(
&:to_i
),
}
end

# Shape returned by record_note_view — mirrors the :topic_view
# serializer's `mod_topic_note_viewers` so the frontend can swap the
# one for the other without a topic reload.
def serialized_note_viewers(viewers)
Array(viewers).map do |entry|
{
user_id: entry["user_id"],
username: entry["username"],
name: entry["name"],
avatar_template: entry["avatar_template"],
viewed_at: entry["viewed_at"],
}
end
end
end
end
46 changes: 46 additions & 0 deletions app/jobs/regular/dedupe_mod_whisper_notifications.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

module ::Jobs
# Removes core Discourse notifications (:replied, :posted, :quoted,
# :mentioned) for users who also received a custom mod_whisper
# notification for the same post. Scheduled with a small delay from
# the on(:post_created) hook so PostAlerter has had time to create
# the duplicates — PostAlerter runs in its own Sidekiq job after
# :post_created, so an inline cleanup races it.
#
# Failure mode: if the job runs before PostAlerter (rare — would mean
# PostAlerter is slower than 5s), the duplicates haven't been
# created yet and the delete is a no-op. The duplicates would then
# stay in the bell. Acceptable degradation; the worst case matches
# the pre-fix behavior.
class DedupeModWhisperNotifications < ::Jobs::Base
def execute(args)
post_id = args[:post_id]
recipient_ids = Array(args[:recipient_ids]).map(&:to_i).reject(&:zero?)
return if post_id.blank? || recipient_ids.empty?

post = ::Post.find_by(id: post_id)
return unless post

removed =
::Notification.where(
user_id: recipient_ids,
topic_id: post.topic_id,
post_number: post.post_number,
notification_type: [
::Notification.types[:replied],
::Notification.types[:posted],
::Notification.types[:quoted],
::Notification.types[:mentioned],
],
).delete_all

return if removed.zero?

# Refresh the bell counts for each affected user so the badge in
# the header reflects the decreased unread total without waiting
# for the next /session/current poll.
::User.where(id: recipient_ids).find_each(&:publish_notifications_state)
end
end
end
Loading