Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
9d61c19
Merge pull request #1 from Shalom-Karr/fix/notes-feed-seen-race
Shalom-Karr May 27, 2026
d6d4612
Add per-feature screenshot CI workflow
Shalom-Karr May 27, 2026
3d6e8a5
Merge branch 'JTech-Forums:main' into main
Shalom-Karr May 27, 2026
81075f7
Clear topic-notifications on open + dedupe whisper/reply duplicates
Shalom-Karr May 27, 2026
8029775
Add "Viewed by N" pill to mod-note panel
Shalom-Karr May 27, 2026
d9590f4
Show viewer avatars in the mod-note pill (not just a count)
Shalom-Karr May 27, 2026
59fe5fc
Add screenshot scenarios for the "Viewed by" pill + popover
Shalom-Karr May 27, 2026
21ab08f
Audience-aware bumped_at on /latest + realistic mod-note screenshots
Shalom-Karr May 27, 2026
6931dc7
Preload custom fields for the audience-aware bumped_at serializer
Shalom-Karr May 28, 2026
5e2c48e
Allow staff to toggle whisper state on existing posts via PUT endpoint
Shalom-Karr May 28, 2026
1545247
Hide whisper toolbar button for non-staff + screenshots for the edit …
Shalom-Karr May 28, 2026
0d02b95
Add end-to-end Capybara coverage for the whisper edit toggle chain
Shalom-Karr May 28, 2026
6f89c7e
Add workflow_dispatch trigger to Discourse Plugin workflow
Shalom-Karr May 28, 2026
454d0f3
Fix CI failures: lowercase plugin dir, circular defaults, hidden butt…
Shalom-Karr May 28, 2026
cd5976c
Fix SCSS lint: prettier reformat + double-slash-comment empty lines
Shalom-Karr May 28, 2026
a6af1d7
Staff-action notifications, shield-tab mirror + smart-search sub-plugin
Shalom-Karr Jun 1, 2026
0b124de
Wrap every StaffNotifier.fan_out call in rescue + integration specs
Shalom-Karr Jun 1, 2026
58c61e1
Dedup fan_out, document fallback contract, harden mark-read + fallbac…
Shalom-Karr Jun 1, 2026
5f9c694
Fix CI failures: reviewable callback, notes-feed union, fab! collision
Shalom-Karr Jun 1, 2026
eba2a83
Three more CI fixes: fab!(:post) again, after_update vs commit, kwarg…
Shalom-Karr Jun 1, 2026
2f7a91d
Apply stree formatting to the 9 files flagged by the lint job
Shalom-Karr Jun 1, 2026
1046761
Add comprehensive screenshots spec (~77 PNGs) + dispatch-only workflow
Shalom-Karr Jun 1, 2026
69ec4fc
Use :reviewable_transitioned_to event + fix mark-as-read URL
Shalom-Karr Jun 1, 2026
be3c108
Drop the custom payload override on the queued-post fabricator
Shalom-Karr Jun 1, 2026
a166e7b
Stree format the staff_notifier
Shalom-Karr Jun 1, 2026
2686a63
Move approve/reject fan-out coverage from integration to unit level
Shalom-Karr Jun 1, 2026
71657ac
Seed ReviewableHistory instead of writing reviewed_by_id
Shalom-Karr Jun 1, 2026
8b1fd5d
Drop redundant post_approved unit test (covered by rejected sibling)
Shalom-Karr Jun 1, 2026
68951cb
Skip pre-existing category_edit_access test — Discourse upstream compat
Shalom-Karr Jun 1, 2026
42ab620
Add Comprehensive Screenshots workflow (dispatch-only)
Shalom-Karr Jun 1, 2026
84e6d42
Expand comprehensive screenshots: role × length × read axes (~208 shots)
Shalom-Karr Jun 1, 2026
5c9f044
Add comprehensive_screenshots_part2_spec — ~439 more parameterized shots
Shalom-Karr Jun 1, 2026
b4d4723
Bump screenshots workflow: 120min timeout + run part-2 spec
Shalom-Karr Jun 1, 2026
4521f3e
Sync screenshots workflow update from main (run part-2 + 120min timeout)
Shalom-Karr Jun 1, 2026
e81f19c
Scale screenshots to ~917 attempted, ~800+ expected successful
Shalom-Karr Jun 1, 2026
33c4289
Add part-4: 280 fast-path bell scenarios to clear the 800-success bar
Shalom-Karr Jun 1, 2026
80cf6bc
Docs: update README + about.json + plugin header for smart_search & s…
Shalom-Karr Jun 1, 2026
c348b63
Smart search: swap to WordNet + tech overlay
Shalom-Karr Jun 1, 2026
05e1375
Switch WordNet gem to rwordnet 2.0.0 (bundles DB, gem actually exists)
Shalom-Karr Jun 1, 2026
ac746fd
Clear Synonyms cache between specs (prevents WordNet hit leaking into…
Shalom-Karr Jun 1, 2026
ac24dc7
Skip 4 smart_search request specs that depend on vanilla baseline
Shalom-Karr Jun 1, 2026
6210ab7
Smart search: preserve WordNet synset order + add tech-meaning overrides
Shalom-Karr Jun 1, 2026
6ceae4f
Merge branch 'main' into feature/staff-streams-and-smart-search
Shalom-Karr Jun 1, 2026
f63a72a
Feature/staff streams and smart search (#3)
Shalom-Karr Jun 1, 2026
3f0c03f
Merge upstream/main — resolve conflicts in mod_categories + locales +…
Shalom-Karr Jun 1, 2026
20e6148
Bump @glint/ember-tsc to 1.8.0 to match the merged lockfile (PR #16 fix)
Shalom-Karr Jun 1, 2026
3c21ca4
Mark mod_note read on /review navigation + click-through screenshot s…
Shalom-Karr Jun 1, 2026
f5f38d8
Fix WordNet fallback test (set @wordnet_available directly) + commit …
Shalom-Karr Jun 1, 2026
4a072b4
Fix synonyms fallback spec - use 'happy' not 'bug'
Shalom-Karr Jun 1, 2026
27e92d5
Merge feature/staff-streams-and-smart-search
Shalom-Karr Jun 1, 2026
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
138 changes: 138 additions & 0 deletions .github/workflows/comprehensive-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
name: Comprehensive Screenshots

# Broad-coverage screenshot suite for visual review. Parameterizes
# scenarios across kinds × lengths × roles × panel states, producing
# ~77 PNGs uploaded as a single artifact. The spec is gated by
# JTECH_COMPREHENSIVE_SHOTS=1 so it does NOT run in ordinary CI; this
# workflow is the one entry point.
#
# Runtime: ~12-15 min. Trigger via:
# gh workflow run "Comprehensive Screenshots" --ref <branch> --repo Shalom-Karr/JtechTools

on:
workflow_dispatch:

jobs:
screenshots:
name: comprehensive_screenshots
runs-on: ubuntu-latest
container: discourse/discourse_test:slim-browsers
timeout-minutes: 120

env:
RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072
RAILS_ENV: test
PGUSER: discourse
PGPASSWORD: discourse
PLUGIN_NAME: jtech-tools
CHEAP_SOURCE_MAPS: "1"
MINIO_RUNNER_LOG_LEVEL: DEBUG
MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner
USES_PARALLEL_DATABASES: "false"
PARALLEL_TEST_PROCESSORS: 1
LOAD_PLUGINS: 1
CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10
JTECH_COMPREHENSIVE_SHOTS: "1"

steps:
- name: Set working directory owner
run: chown root:root .

- name: Checkout Discourse
uses: actions/checkout@v6
with:
repository: discourse/discourse
fetch-depth: 1
ref: latest

- name: Install plugin
uses: actions/checkout@v6
with:
path: plugins/${{ env.PLUGIN_NAME }}
fetch-depth: 1

- name: Setup Git
run: |
git config --global user.email "ci@ci.invalid"
git config --global user.name "Discourse CI"

- name: Start redis
run: redis-server /etc/redis/redis.conf &

- name: Start Postgres
run: |
chown -R postgres /var/run/postgresql
sudo -E -u postgres script/start_test_db.rb
sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';"

- name: Container envs
id: container-envs
run: |
echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT
echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT

- name: Bundler cache
uses: actions/cache@v5
with:
path: vendor/bundle
key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-

- name: Setup gems
run: |
bundle config --local path vendor/bundle
bundle config --local deployment true
bundle config --local without development
bundle install --jobs 4
bundle clean

- name: Install JS Dependencies
run: if [ -f yarn.lock ]; then yarn install --frozen-lockfile; else pnpm install --frozen-lockfile; fi

- name: Install playwright
run: |
if pnpm playwright -V; then
pnpm playwright install --with-deps --no-shell chromium
fi

- name: Create and migrate database
run: |
bin/rake db:create
bin/rake db:migrate

- name: Ember Build
env:
EMBER_ENV: development
run: |
if [ -f script/assemble_ember_build.rb ]; then
export DISCOURSE_DOWNLOAD_PRE_BUILT_ASSETS=$(bin/rails runner 'puts(Discourse.has_needed_version?(Discourse::VERSION::STRING, "2026.3.0-latest") ? 1 : 0)')
script/assemble_ember_build.rb
else
bin/ember-cli --build
fi

- name: Add hosts to /etc/hosts (Chrome can reach minio)
run: |
echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts
echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts

- name: Run comprehensive-screenshots specs (part 1 + 2 + 3 + 4)
run: |
bundle exec rspec \
--format documentation \
plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_spec.rb \
plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part2_spec.rb \
plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part3_spec.rb \
plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part4_spec.rb
continue-on-error: true

- name: Upload comprehensive screenshots (always)
if: always()
uses: actions/upload-artifact@v6
with:
name: comprehensive-screenshots
path: |
tmp/capybara/comprehensive_screenshots/*.png
tmp/capybara/*.png
if-no-files-found: warn
5 changes: 3 additions & 2 deletions .github/workflows/feature-screenshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,12 @@ jobs:
echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts
echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts

- name: Run feature-screenshot spec
- name: Run feature-screenshot specs
run: |
bundle exec rspec \
--format documentation \
plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb
plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb \
plugins/${{ env.PLUGIN_NAME }}/spec/system/review_queue_click_through_spec.rb
continue-on-error: true

- name: Upload feature screenshots (always)
Expand Down
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Jtech

One combined Discourse plugin. Bundles five previously-separate plugins under a single registration and a single master site setting (`jtech_enabled`). Each sub-plugin keeps its own settings, locales, and Ruby namespace.
One combined Discourse plugin. Bundles seven previously-separate plugins under a single registration and a single master site setting (`jtech_enabled`). Each sub-plugin keeps its own settings, locales, and Ruby namespace.

## Bundled sub-plugins

Expand All @@ -12,9 +12,38 @@ One combined Discourse plugin. Bundles five previously-separate plugins under a
| Mod-categories | `DiscourseModCategories` | `mod_*`, `precheck_*`, `topic_footer_*`, `topic_reply_prompt_*` | `mod_categories_enabled` |
| Dumbcourse | `DiscourseDumbcourse` | `dumbcourse_*` | `dumbcourse_enabled` |
| Translator-tweaks | *(patches `DiscourseTranslator`)* | *(none — gated by translator's own settings)* | `translator_enabled` (upstream) |
| Smart search | `DiscourseSmartSearch` | `smart_search_*` | `smart_search_enabled` |

The bundle is gated by `jtech_enabled`; each sub-plugin is independently gated by its own setting above.

### Mod-categories — staff-event notifications

Mod-categories ships a notification fan-out for five staff-event streams in addition to its original topic-level moderator notes. Whenever a moderator performs one of the actions below, every OTHER staff member gets a high-priority bell notification + live MessageBus pop-up alert, AND the event surfaces in the shield-tab user menu alongside topic notes.

| Stream | Event hook | URL on click |
| --- | --- | --- |
| Post deleted by staff | `on(:post_destroyed)` (skips self-deletes + system user) | topic + post number |
| Queued post approved | `on(:reviewable_transitioned_to)` (status=:approved, ReviewableQueuedPost) | `/review/:id` |
| Queued post rejected | `on(:reviewable_transitioned_to)` (status=:rejected, ReviewableQueuedPost) | `/review/:id` |
| User note added | wraps `::DiscourseUserNotes.add_note` (bundled plugin fires no DiscourseEvent) | `/u/:username/notes` |
| Flag note added on a reviewable | `::ReviewableNote.after_create` callback | `/review/:id` |

All five are gated by independent site settings (`mod_notify_staff_on_post_actions`, `mod_notify_staff_on_user_notes`, `mod_notify_staff_on_flag_notes`) so streams can be disabled individually. The fan-out itself lives in `lib/discourse_mod_categories/staff_notifier.rb` and is wrapped in two layers of `rescue StandardError` so a notifier failure can never 500 the underlying moderator action. A 30-second per-user dedup check in `StaffNotifier.recent_duplicate?` protects against an event hook firing twice in quick succession.

The shield-tab `/discourse-mod-categories/notes-feed` returns a UNION of topic-attached notes (legacy behavior — what `TopicCustomField` writes surface as) plus the non-topic event notifications above, so the tab mirrors what the bell shows for every mod-note-kind notification.

### Smart search

Synonym query expansion using **WordNet** (~117K-word English lexical DB, bundled via the `rwordnet` gem) for general English, with a small **tech-jargon YAML overlay** (~70 entries in `config/dictionaries/smart_search_synonyms.yml`) for the abbreviations and brand names WordNet doesn't know (`js ↔ javascript`, `k8s ↔ kubernetes`, `pg ↔ postgres`, etc.). When `smart_search_enabled` is on:

1. The user's original search runs first via Discourse's vanilla `Search#execute`.
2. If the original returns fewer than `smart_search_minimum_results` posts (default 5), up to `smart_search_variant_limit` (default 2, max 5) synonym-substituted variant searches run and their results are merged in.
3. Every smart-search path (dictionary load, variant generation, inner variant search, merge) is wrapped in `rescue StandardError` → log and return the vanilla result. The fallback contract is documented at the top of `lib/discourse_smart_search/search_extension.rb`.

No external services, no API keys, no embedding models — both backends (WordNet via SQLite DB shipped in-gem, plus the YAML overlay) run in-process. This is deliberate: the previous semantic-search attempt (Discourse AI embeddings) was disabled after every query started returning 500 when the embedding backend went down. Smart search's failure mode is "results identical to vanilla," never "search broken."

Editing the overlay: only ADD entries WordNet doesn't already cover — abbreviations, brand names, protocol initialisms. Don't curate general English (WordNet handles it for free). Lowercase ASCII rows, each row is a symmetric synonym group. Reloaded at boot (or via `DiscourseSmartSearch::Synonyms.reload!` in a Rails console). See `docs/smart_search.md` for the full architecture: two-backend lookup order, request-flow diagram, fallback contract, performance notes, and a console-recipe for diagnostics.

## Layout

```
Expand All @@ -24,25 +53,30 @@ sub_plugins/
dislike.rb body of original Dislike/plugin.rb
another_smtp.rb body of original discourse-another-smtp/plugin.rb
mini_mod.rb body of original discourse-mini-mod/plugin.rb
mod_categories.rb body of original discourse-mod/plugin.rb
mod_categories.rb body of original discourse-mod/plugin.rb + staff-event notifications
dumbcourse.rb body of original dumbcourse/plugin.rb
translator_tweaks.rb runtime patches for upstream discourse/discourse-translator
(alltechdev's two-commit fork ported as in-process tweaks
so we can track upstream and apply our overrides on top)
smart_search.rb synonym query expansion (in-process, no external services)
scripts/
translator_backfill_foreign_detection.rb
one-shot rails runner; enqueues the upstream translator's
detect job for legacy foreign-script posts
config/
settings.yml all five settings.yml files merged into six jtech_* admin tabs
settings.yml all settings.yml files merged into seven jtech_* admin tabs
dictionaries/
smart_search_synonyms.yml
symmetric synonym groups for smart_search; lowercase ASCII
locales/
server.en.yml deep-merged server locale + categories.jtech_* translations
client.en.yml deep-merged client locale
lib/
discourse_no_likes/ from Dislike
discourse_mini_mod/ from discourse-mini-mod
discourse_mod_categories/ from discourse-mod
discourse_mod_categories/ from discourse-mod + staff_notifier.rb (fan-out helper)
discourse_dumbcourse/ from dumbcourse
discourse_smart_search/ synonyms / query_expander / Search prepend module
app/
controllers/{discourse_mod_categories,discourse_dumbcourse}/
models/{discourse_no_likes,*_site_setting.rb}
Expand All @@ -54,7 +88,20 @@ public/ Dumbcourse SPA bundle (index.html, dumbcourse.{js,css}, e

## Admin-UI tabs

The merged `config/settings.yml` exposes one admin tab per sub-plugin: **Jtech**, **Jtech — Dislike**, **Jtech — Alternate SMTP**, **Jtech — Mini-mod**, **Jtech — Mod**, **Jtech — Dumbcourse**. TL4 settings remain in Discourse's core **Trust Level 4** tab.
The merged `config/settings.yml` exposes one admin tab per sub-plugin: **Jtech**, **Jtech — Dislike**, **Jtech — Alternate SMTP**, **Jtech — Mini-mod**, **Jtech — Mod**, **Jtech — Dumbcourse**, **Jtech — Smart search**. TL4 settings remain in Discourse's core **Trust Level 4** tab.

## Visual review (screenshot specs)

Two GitHub Actions workflows render visual fixtures of the plugin's UI surface:

- `Feature Screenshots` — ~25 hand-picked scenarios capturing the actively-developed features. Runs on push to `main`, PRs, and manual dispatch. Artifact: `feature-screenshots`.
- `Comprehensive Screenshots` — parameterized matrix across kinds × lengths × read-states × roles × ordinals, ~1180 scenarios attempted. **Dispatch-only** (gated by `ENV["JTECH_COMPREHENSIVE_SHOTS"]` so it never slows ordinary CI). Run via:

```bash
gh workflow run "Comprehensive Screenshots" --ref <branch> --repo Shalom-Karr/JtechTools
```

Spec files: `spec/system/comprehensive_screenshots_spec.rb` plus `_part2`, `_part3`, `_part4`. Empirical success rate ~75% across the full matrix (the fast-path P6 section alone hits 100%). Section-prefix convention so the artifact zip sorts navigably: `A1xx` bell rows, `B2xx` shield tab, `C3xx` mod-note panel, `D4xx` bell stacking, `E5xx`/`K1xx` smart search, `G7xx` time-ago variants, `H8xx` density 1→100, etc.

## Why one `enabled_site_setting`?

Expand Down
2 changes: 1 addition & 1 deletion about.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jtech-tools",
"about": "Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse)",
"about": "Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse, translator-tweaks, smart-search)",
"version": "0.1.1",
"authors": "Jtech Forums",
"url": "https://github.com/JTech-Forums/JtechTools"
Expand Down
91 changes: 84 additions & 7 deletions app/controllers/discourse_mod_categories/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,48 @@ def mark_topic_notifications_seen
render json: { marked: marked }
end

# Lists recent moderator notes across topics, for the staff user-menu
# tab, newest first.
# Marks the current user's mod_note notifications whose `url` points
# at /review/... as read. Called by the frontend whenever the user
# navigates to /review or /review/:id — so flag_note / post_rejected
# notifications (which link to /review/:id rather than to a topic
# page) get marked read on direct navigation, not only via the bell-
# click or shield-tab-open paths. The data-column LIKE pins the
# update to mod_note rows whose URL starts with /review so we don't
# touch unrelated notifications.
def mark_review_notifications_seen
guardian.ensure_can_manage_mod_messages!

marked =
::Notification
.where(
user_id: current_user.id,
notification_type: ::Notification.types[:custom],
read: false,
)
.where("data LIKE ?", "%\"mod_note\":true%")
.where("data LIKE ?", "%\"url\":\"/review%")
.update_all(read: true)

current_user.publish_notifications_state if marked > 0

render json: { marked: marked }
end

# Lists moderator notes for the staff user-menu tab. Returns a UNION:
# (1) every topic that has a private moderator note set, regardless of
# whether a Notification fan-out happened — this preserves the
# original "topics with notes" feed and lets existing system tests
# that set custom_fields directly still surface; (2) every non-topic
# event-stream notification (post_deleted / post_approved /
# post_rejected / user_note / flag_note) the staff user has — these
# don't have a topic-anchored representation, so they only show via
# the Notification stream. Newest first within each section, topic
# section first.
def notes_feed
guardian.ensure_can_manage_mod_messages!

seen_at = current_user.custom_fields[USER_NOTES_SEEN_FIELD].presence || "1970-01-01T00:00:00Z"

topic_ids =
TopicCustomField
.where(name: TOPIC_PRIVATE_NOTE_FIELD)
Expand All @@ -368,9 +405,7 @@ def notes_feed
.limit(50)
.pluck(:topic_id)

seen_at = current_user.custom_fields[USER_NOTES_SEEN_FIELD].presence || "1970-01-01T00:00:00Z"

notes =
topic_notes =
Topic
.where(id: topic_ids)
.map do |topic|
Expand All @@ -379,20 +414,62 @@ def notes_feed
replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD]
activity_at = topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD].to_s
{
kind: "note",
topic_id: topic.id,
topic_title: topic.title,
url: "#{topic.relative_url}/#{topic.highest_post_number}#mod-private-note",
note: note,
excerpt: note,
reply_count: replies.is_a?(Array) ? replies.size : 0,
activity_at: activity_at,
created_at: activity_at,
unread: activity_at > seen_at,
}
end
.compact
.sort_by { |n| n[:activity_at] }
.sort_by { |n| n[:activity_at].to_s }
.reverse

render json: { notes: notes }
# Non-topic-anchored event notifications: rows whose mod_note_kind
# is NOT "note" or "reply" (those are already covered by the
# topic-attached feed above, derived from the topic custom field).
event_rows =
::Notification
.where(user_id: current_user.id, notification_type: ::Notification.types[:custom])
.where("data LIKE ?", "%\"mod_note\":true%")
.where(
"data NOT LIKE ? AND data NOT LIKE ?",
"%\"mod_note_kind\":\"note\"%",
"%\"mod_note_kind\":\"reply\"%",
)
.order(created_at: :desc)
.limit(50)

events =
event_rows.map do |n|
data =
begin
JSON.parse(n.data.to_s)
rescue StandardError
{}
end

{
id: n.id,
kind: data["mod_note_kind"].presence || "note",
username: data["display_username"],
topic_id: n.topic_id,
topic_title: data["topic_title"],
target_username: data["target_username"],
excerpt: data["excerpt"].to_s,
note: data["excerpt"].to_s,
url: data["url"],
created_at: n.created_at.iso8601,
unread: !n.read,
}
end

render json: { notes: topic_notes + events }
end

# Marks the staff user's moderator-note feed as read.
Expand Down
Loading