When bulk operations partially fail (e.g. moving topics to a category
that doesn't allow their tags), the modal closes and a vague toast says
"could not be completed for X topics" with no explanation. Admins have
to check server logs to understand what went wrong.
Keep the modal open and display a detailed summary:
- how many topics were updated
- how many were already up to date (skipped)
- how many could not be updated, with the specific error messages
Backend: expose `TopicsBulkAction#errors` via `attr_reader` and include
the errors hash in the bulk endpoint JSON response when present.
Frontend: accumulate errors across chunked requests in `_processChunks`,
then display the results in the modal body instead of closing it. The
topic list is refreshed when the user dismisses the modal via "Close".
**BEFORE (toast)**
<img width="484" height="72" alt="2026-02-13 @ 09 37 54"
src="https://github.com/user-attachments/assets/a6faaf18-e135-4dda-a4c6-5f1467acf7a1"
/>
**AFTER (details in modal)**
<img width="805" height="328" alt="2026-02-13 @ 09 19 36"
src="https://github.com/user-attachments/assets/cafbe851-9016-4f46-9a8a-8d8f3ceb70cc"
/>
- Removes double private keyword
- Fixes test mutating json as string, instead of json as hash. Calling
`string["description"] = ""` uses Ruby’s `String#[]=` method which finds
the substring `description` and replaces it with `""`, corrupting the
JSON but leaving it parseable with a `nil` description field instead of
`""`.
This commit adds the backend for a dedicated tag settings page (part of
#37170 split, since the PR is huge). The frontend will follow in a
separate PR.
New settings and controller actions on `TagsController` allow fetching
and updating tag settings via `/tag/:id/settings.json`. Updates are
handled by `TagSettingsUpdater` in transaction.
Some details
- TagSettingsSerializer returns raw (unlocalized) tag and synonym names
for editing
- Tag renames are logged via StaffActionLogger
- Tag#ensure_slug now respects explicit slug changes, allowing
independent slug editing
The monolithic `CategoryHierarchicalSearch` service mixed query
building, eager loading, and pagination logic in a single class. The
query was a large raw SQL string built through conditional string
concatenation. Fragments like `#{matches_sql}`, `#{only_ids_sql}`,
`#{except_ids_sql}` were stitched together, with LIMIT/OFFSET appended
via ternary interpolation and named placeholders passed through a
manually assembled hash. This made the query fragile and hard to follow.
Break it into focused, single-responsibility classes under the
`Category::` namespace:
- `Category::HierarchicalSearch` — service orchestrator using
`Service::Base`, with a contract that owns pagination logic (page
validation, limit/offset computation)
- `Category::Query::HierarchicalSearch` — query object that uses
ActiveRecord's interface where it naturally fits (`.where()` with
parameter binding, `.limit()`, `.offset()`, `.with()`, `.joins()`) and
isolates the genuinely complex SQL (recursive CTEs, term matching) into
small named methods rather than a monolithic heredoc
- `Category::Action::EagerLoadAssociations` — extracted eager loading
into a reusable `Service::ActionBase`
The controller is simplified to a single
`Category::HierarchicalSearch.call(service_params)` call with proper
`on_success` / `on_failed_contract` / `on_failure` handling, replacing
manual param transformation and direct result access.
Specs are rewritten to test each class in isolation: the service spec
stubs its collaborators to verify orchestration, the query spec
exercises actual SQL behavior, and the action spec verifies preloading.
Service structure and spec patterns follow the [Discourse service object
guidelines](https://meta.discourse.org/t/using-service-objects-in-discourse/333641)
and the [RSpec Style Guide](https://rspec.rubystyle.guide/).
The original commit
https://github.com/discourse/discourse/commit/ce2ca19d added a
frontend-only restriction to hide the "delete all posts" option when penalizing users at trust level 2 or above. However, this restriction was not enforced on the server side.
This is not security as it's mostly a safeguard and not a security feature. The same user could delete posts one by one if they wanted to.
There's no significant security issue as this will get caught by a
guardian later and the rate limiting is scoped to the remote ip, so an
attacker can't rate limit a victim and prevent them to use this
endpoint.
With `content_localization_enabled`, searching for tags via any tag
dropdowns (or hashtag autocompletes) triggers a 500 error. The crash
occurs because a preloader is called on a non-AR result returned by
`DiscourseTagging.filter_allowed_tags` :sadpikachu:, which are plain
structs, not AR Tags instances. Additionally, `tag_counts_json` calls AR
methods (which access `.locale` that don't exist on the minisql objects.
This PR fixes the issue by converting mini results to Tag ARs with
`.includes(:localizations)` before passing them to tag_counts_json.
- TagsController#search
- TagHashtagDataSource#search
- TagHashtagDataSource#search_without_term
There's a bug now when navigating to a tag synonym via the tag dropdown
would show some inconsistencies in the URL and the dropdown. (URL
showing synonym, dropdown showing synonym's target). This is also
evident with the tag banners theme component.
This fix ensures that the user will only see the target tag by making
sure we route to the target immediately from the dropdown, without
triggering a refresh or redirect.
Watched Words (Replace) caused recursive nesting when editing posts in
the rich editor. Each edit added another layer of replacement text, e.g.
"ETA" → "Estimated Time of Arrival (ETA)" → "Estimated Time of Arrival
(Estimated Time of Arrival (ETA))".
The ProseMirror editor reuses markdown-it to parse raw markdown into its
document tree. This markdown-it engine included the `watched-words` and
`censored` features, which are rendering-only transforms meant for
cooked output. When parsing raw into the ProseMirror AST, these features
replaced text content. On save, ProseMirror serialized the AST back to
markdown with the replacements baked into the new raw — which then got
replaced again on the next cook, causing recursive nesting for `replace`
and permanent content destruction for `censor`.
Adds `watched-words` and `censored` to the omit list for the ProseMirror
markdown-it engine, alongside the already-omitted `onebox`. These
features now only run during server-side cooking where they belong.
Ref - https://meta.discourse.org/t/241735/34
Clicking “Resend confirmation email” to an unconfirmed alternate email
address creates a duplicate row that persists after hard refresh.
### The problem
When adding an alternate (secondary) email, within
`lib/email_updater.rb` the value for `add` is true and `old_email`
should be nil — there is no "old email" being replaced, you're just
adding a new one. But the old code always passed @user.email (the
primary email) as old_email in the find_or_initialize_by query.
This caused a duplicate row because:
1. The first time a user adds an alternate email, a row is created with
old_email: nil (lib/email_updater.rb:57 sets it to nil after the
find/initialize).
2. If the user tries the same flow again (e.g. re-requesting
confirmation), find_or_initialize_by searches for a row matching
{user_id: X, old_email: "primary@example.com", new_email:
"alt@example.com"}. That doesn't match the existing row (which has
old_email: nil), so it initializes a new record instead of finding the
existing one.
3. Line 57 then sets old_email = nil on this new record, and when it's
saved, you get a duplicate row — two EmailChangeRequest records with
identical user_id, old_email: nil, and new_email.
### Solution
By passing nil directly in the find_or_initialize_by when add is true,
the query now correctly matches the existing row (old_email: nil), so it
finds the existing request instead of creating a duplicate. The
find_or_initialize_by lookup is now consistent with the value that
actually gets persisted.
Internal ref: /t/129754
Categories with accented names (e.g. "Éditions") were not appearing in
search results, category chooser dropdowns, or hashtag autocomplete when
searching with unaccented terms (e.g. "editions").
PostgreSQL's `ILIKE` and `LOWER()` are case-insensitive but not
accent-insensitive, so queries like `name ILIKE '%editions%'` would not
match "Éditions".
This wraps all category name/slug comparisons with PostgreSQL's
`unaccent()` function across:
- `/categories/search` endpoint
- `/categories/hierarchical_search` endpoint
- Hashtag (#) autocomplete
- `category:` and `#slug` search filters
Introduces `Category.normalize_sql(expr)` helper that wraps SQL
expressions with `lower(unaccent(...))` to centralize the normalization
logic and make it easier to extend in the future.
On the frontend, adds a shared `removeAccents()` utility using NFD
normalization for client-side `Category.search()` and search filter
suggestions.
Ref - https://meta.discourse.org/t/395355
What is the problem?
The tag groups search endpoint (`TagGroupsController#search`) returns
tag data with only `id` and `name` fields. The `slug` field is missing
from the response, which can cause issues on the client side when it
needs the slug to build URLs or match tags correctly — especially for
tags whose slug differs from their name.
What is the solution?
Add `slug` to the `pluck` and `map` calls in
`TagGroupsController#search` so the response includes the tag slug
alongside `id` and `name`.
The original commit fixed an issue where adding hidden tags to a post
would incorrectly increment `public_version`, causing the edit pencil
icon to appear for regular users who couldn't access the hidden
revision. The fix correctly skips incrementing `public_version` for
hidden revisions. However, the commit introduced a bug in
`update_revision` - when a hidden revision is destroyed (e.g., by
reverting changes within the grace period), `public_version` is
incorrectly decremented even though it was never incremented for that
revision.
This is an admin only feature, we had not validation that the webhook
event we were trying to redeliver was part of the webhook
This cleans this up and makes code more consistent
Add `target_user_ids` as an alternative to `target_usernames` when
creating private messages via PostCreator and TopicCreator. The two
options are mutually exclusive and raise ArgumentError if both are
provided.
This avoids username-based lookups where the caller already has user
IDs, and prevents stale references when users rename after a pending
PM is created.
Key changes:
- PostCreator/TopicCreator: accept `target_user_ids`, validate
mutual exclusivity with `target_usernames`, extract shared
`add_users_from_scope` in TopicCreator
- Automation PendingPm: migrate from `sender`/`target_usernames`
string columns to `sender_id`/`target_user_ids` integer columns
with a backfill migration
- Scriptable::Utils.send_pm: resolve sender upfront, support
integer sender param, store IDs in pending PMs
When a user replies to a topic and then switches to "Reply as linked
topic" via the composer actions menu, the original reply draft was left
behind as an orphan. This happened because `_replyFromExisting()` called
`closeComposer()` which only nullifies the model without destroying the
draft. The new linked topic composer then opens with a different draft
key (`new_topic_<timestamp>`), leaving the old reply draft
(`topic_<id>`) persisted in the database.
Fix this by calling `destroyDraft()` before closing and reopening the
composer in `_replyFromExisting()`. This ensures the old draft is
cleaned up when switching composer actions, regardless of whether the
draft key changes.
https://meta.discourse.org/t/394431
Automatic group names are translated to the forum's default locale (e.g.
"jeder" in German). The category permission warning messages and
inherited permission tooltips were hardcoding the English "everyone"
name, which created a mismatch when the admin UI showed the localized
group name.
Replace the hardcoded "everyone" in the four affected translation
strings with a `%{everyone_group}` placeholder and pass the actual group
name from the permission data at each call site.
https://meta.discourse.org/t/395809
When a staff member applies "Staff Colour" to a post, it changes
`post_type` from `regular` (1) to `moderator_action` (2). The
`NotificationEmailer` only sent emails for `regular` and `whisper` post
types, so staff-coloured posts silently skipped email delivery. The same
issue affected `PostAlerter`'s `@here` mention expansion which also
filtered on post type.
This is safe to fix because the only two places that needed updating are
`NotificationEmailer::EMAILABLE_POST_TYPES` and
`PostAlerter#expand_here_mention`. System-generated moderator posts
(close/split/merge) never reach this code — `PostJobsEnqueuer` skips
them via `skip_after_create?`, so they never generate the notification
types that flow through `NotificationEmailer`. Similarly, system
moderator posts don't contain `@here` mentions, so adding
`moderator_action` to `expand_here_mention` is equally safe.
https://meta.discourse.org/t/370857
The category tags page and webhooks form were using `@form.Container`
and `field.Custom` to wrap the select-kit TagChooser component. These
wrappers are simple visual containers that don't participate in
FormKit's field lifecycle — they silently ignore props like `@optional`
and don't support validation, error display, or consistent field
styling.
This commit introduces a proper `field.TagChooser` FormKit control that
wraps the select-kit TagChooser, mapping `@field.value` to `@tags` and
`@field.set` to `@onChange`. It follows the same pattern as the existing
`field.Icon` control.
Changes:
- New `fk/control/tag-chooser.gjs` with pass-through args for
TagChooser-specific options (`@everyTag`, `@excludeSynonyms`, etc.)
- Register the control in `fk/field.gjs` and add pass-through args to
`fk/control-wrapper.gjs`
- Convert `upsert-category/tags.gjs` allowed_tags from Container to
`field.TagChooser`
- Convert `webhooks-form.gjs` tag_names from `field.Custom` to
`field.TagChooser`
- Add `tag-chooser` support to the FormKit Ruby page object for system
specs
- Update simplified_category_creation_spec to use FormKit page objects
- Add integration tests and styleguide example
- Add webhook tag filter system spec
Ref - t/174117
We were using `Discourse.InvalidParameters(...)` instead of
`Discourse::InvalidParameters.new(...)` which would raise a
`NoMethodError` (500) instead of the correct invalid parameters error.
This codepath was not tested and this commit adds a test for it.
What is the problem?
When `s3_enable_access_control_tags` is enabled, S3 uploads are tagged
with a configurable access control tag (default key: `discourse:acl`) to
indicate visibility. During multipart upload completion and file copies,
`S3Helper#copy` receives a `tagging` option with the desired tags for
the destination object. However, AWS S3 defaults `tagging_directive` to
"COPY", which preserves the source object's tags instead of applying the
new ones. This causes uploads to retain incorrect access control tags
after being copied.
What is the solution?
Set `tagging_directive: "REPLACE"` in `S3Helper#copy` whenever
`options[:tagging]` is present. This mirrors the existing
`metadata_directive: "REPLACE"` pattern already used for metadata and
tells the S3 API to apply the provided tags to the destination object.
## Summary
The original commit introduced `depends_behavior: :hidden` for site
settings, allowing settings to be automatically hidden when their
`depends_on` dependencies are false. However, there was a logic bug
where settings that were explicitly marked as `hidden: true` could
become visible if their `depends_on` settings were all true, because the
code overwrote the `is_hidden` flag instead of using OR logic.
## Source
- Patch Triage: https://patch.discourse.org/patch-triage/53
- Original Commit: https://github.com/discourse/discourse/commit/60259dd
---
🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
Switches tag URLs from name-based (`/tag/my-tag`) to slug+id-based
(`/tag/my-tag/123`), making tag references stable across renames and
enabling translated tag names.
| Type | Before | After |
|------|--------|-------|
| Browser | `/tag/my-tag` | `/tag/my-tag/123` |
| Browser | `/tag/my-tag/l/latest` | `/tag/my-tag/123/l/latest` |
| API | `/tag/my-tag.json` | `/tag/123.json` |
- Old `/tag/:name` URLs still work
- Browser requests get 301 redirected to canonical URLs
- API (JSON) requests return data without redirect
- Untagged (`/tag/none`) and intersection routes remain unchanged.
Depends on https://github.com/discourse/discourse/pull/36678
---------
Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
Previously, bulk moving topics to a different category would silently
fail when topics had tags not allowed in the destination category. The
operation reported success but topics remained in the original category.
This change:
- Unifies the category change code path to always use
`first_post.revise()` which validates tag restrictions via
`DiscourseTagging.validate_category_tags()`
- Tracks the count of topics that couldn't be moved
- Shows a warning toast indicating how many topics failed to move
Users now receive clear feedback when some topics cannot be moved due to
tag restrictions between categories.
Note: Moving topics works correctly when:
- Both categories share the same tag group
- The destination category has `allow_global_tags: true` and the topic
has unrestricted (global) tags
The approve/reject buttons for custom group changes on the admin user
page would remain visible after saving, even though the changes were
persisted successfully. Removing a group also failed to update the UI.
Three issues in the reactivity chain caused this:
1. The `@filter` computed macros (classic Ember) on the AdminUser model
did not react to `@trackedArray` mutations. Replaced `customGroups` and
`automaticGroups` with native getters so they participate in Glimmer
autotracking.
2. The controller's `groupAdded()` and `groupRemoved()` wrappers did not
return their promise chains, so `await` in `saveCustomGroups()` resolved
immediately before the API calls completed and before the groups array
was updated.
3. The controller's `customGroupIds`, `customGroupsDirty`, and
`automaticGroups` used `@discourseComputed` which could not track
changes from native getters on the model. Converted these to native
getters and made `customGroupIdsBuffer` `@tracked`.
Additionally, `saveCustomGroups()` now awaits each operation
sequentially and calls `resetCustomGroups()` on completion to sync the
buffer with the updated group list, clearing the dirty state.
Note: only the properties involved in the groups reactivity chain were
converted to modern Glimmer patterns. The rest of the controller and
model still use classic Ember (`@discourseComputed`, `this.set`, etc.)
and can be modernized separately.
Ref - https://meta.discourse.org/t/395210
Two issues were found in BadgeGranter that could prevent badges from
being granted.
First, `Array#compact!` returns `nil` when no elements are removed. For
PostAction triggers, `[post_id, related_post_id].compact!` returns `nil`
when `related_post_id` is present (nothing to compact), causing the
post_ids payload to be silently lost. Using `compact` instead always
returns the array.
Second, `process_queue!` had no error isolation between badges. A single
badge with a broken SQL query would raise an exception and abort
processing of all remaining badges in the queue. Since queue items are
already popped from Redis at that point, they are lost. Each badge is
now rescued individually so one failure doesn't block the rest.
https://meta.discourse.org/t/394444
Related:
https://meta.discourse.org/t/yet-another-title-localization-issue/395469
### bug context
tldr; `Topic#fancy_title` saves a fancy_title to db when the fancy_title
is null.
This bug requires a certain incantation to trigger.
- Topic 395465 exists with `title = "Notification level button always
says \"tracking\""` and
`fancy_title = NULL` in the DB
- A `TopicLocalization` exists for this topic in `zh_CN`
- `content_localization_enabled` is on
When a crawler hits `GET /t/.../395465?tl=zh_CN`, localization
replacement happens on the topic, which writes the title attribute, so
the state is now
- `title` = Chinese (modified)
- `fancy_title` = NULL (untouched, still what was loaded from DB)
When serializing via `TopicViewSerializer` which uses
`LocalizedFancyTopicTitleMixin`, we call `topic.fancy_title`. The
`topic#fancy_title` generates the fancy_title from the title value, then
writes the chinese fancy title to db 😢https://github.com/discourse/discourse/blob/e935ed63b28a30ee7ae6a7783ae05fe33edf3367/app/models/topic.rb#L532-L545
### fix
This commit fixes the issue by ensuring the fancy_title is always written
along with the title, preventing the need for invoking
`topic#fancy_title`.
When multiple replies are collapsed into a single notification,
`PostAlerter` sets `display_username` to a translated reply count (e.g.,
"7 réponses") for use in the notification dropdown UI. The
`original_username` field always holds the actual poster's username.
However, if `original_username` is ever missing from the notification
data, `notification_email` falls back to `display_username`, which
causes the reply count string to be used as the email sender username.
This leads to errors during email rendering.
This updates the fallback chain in `notification_email` to prefer
`post.user.username` over `display_username`, ensuring a real username
is always used even when `original_username` is absent.
https://meta.discourse.org/t//395420
The sidebar "New category" action and the `/new-category` route were
checking `currentUser.admin` directly, which excluded moderators even
when the `moderators_manage_categories` site setting was enabled.
This is inconsistent with the backend Guardian which already allows
moderators to create categories when that setting is on.
Added `can_create_category` to `CurrentUserSerializer` (delegating to
`Guardian#can_create_category?`) and updated the sidebar component and
the new-category route to use it instead of manual role checks.
Ref - https://meta.discourse.org/t/395441
When pressing B then Enter to bookmark a topic, the bookmark icon next
to the topic title and the topic footer bookmark button did not update
until page reload.
The keyboard shortcut path opens the BookmarkModal directly via the
topic controller, bypassing the TopicBookmarkManager that the footer's
BookmarkMenu component relies on. The afterSave callback correctly sets
`topic.bookmarked = true`, but the `topicBookmarkManager` computed
property only depended on `"topic"` (the object identity), so it never
recreated the manager to pick up the new bookmark state.
Changed the dependent key to `"topic.bookmarked"` so the manager is
recreated whenever the bookmarked property changes, regardless of which
code path triggered it.
## Summary
The original commit added a new Discourse ID settings page with the
ability to enable/disable Discourse ID and regenerate credentials.
However, the `update_settings` action used direct assignment to
`SiteSetting.enable_discourse_id` which bypasses the staff action
logging mechanism, meaning changes to this setting were not being
recorded in the admin logs.
Followup 42da6860fd
When the upcoming change for "Impersonate without logout" was added, we
didn't take into account that the "stop impersonation" action would not
work if the Staff enabled option for the upcoming change was used.
This was happening because in the ImpersonateController#destroy action
we werecheckingif the current user had `impersonate_without_logout`
enabled, but we should be checking if the acting user had that
permission instead (i.e. the original admin not the user they are
impersonating)
c.f.
https://meta.discourse.org/t/new-bug-with-experimental-impersonation-interface/395621
This allows us to quickly link from the upcoming changes page
to any related settings, since sometimes after the change is enabled,
more settings may need to be configured. The current need for this
is the `enable_custom_splash_screen` setting.
We can use the status of upcoming changes to indicate
whether they are experimental or not, having experimental
in the setting name is redundant.
Migrates the settings and the upcoming change events,
updates code, and updates yaml translation keys.
Both the allowed_tags and allowed_tag_groups fields were not
correctly saving in the Tabs form when simplified category creation
was enabled. This commit fixes the issue and adds a spec to cover
it.