- 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.
What is the problem?
The poll plugin does not handle the `post_moved` event fired by
`PostMover`. For normal reply moves `Post#id` is unchanged so poll
data is retained, but when moving the first post of a topic
(`PostMover` recreates it via `PostCreator#create!` in the
destination topic) or when an admin moves a reply with the "keep a
copy in the original topic" option (`PostMover#move` with
`freeze_original: true`, which duplicates the post), the new post
gets a different `Post#id` and the `Poll` records remain pointing at
the old `Post#id` via `Poll#post_id`, orphaning them along with
their associated `PollOption` and `PollVote` records.
What is the solution?
Add an `on(:post_moved)` handler in `plugins/poll/plugin.rb` that:
1. Skips processing when `Post#id` is unchanged (normal reply moves).
2. Removes empty `Poll` and `PollOption` records that the poll
plugin's `validate_post` hook auto-creates when `PostCreator`
rebuilds the post from raw. Deletes in FK order: `PollVote` →
`PollOption` → `Poll`.
3. Reassigns the original polls — which carry the actual votes — to
the new post by updating `Poll#post_id` via `Poll.update_all`.
4. Updates `DiscoursePoll::HAS_POLLS` custom field on both posts via
`PollsUpdater.update_post_custom_fields` — sets it on the new
post (needed for the `freeze_original` path which bypasses
`PostCreator`) and clears it on the old post so
`TopicView#polls` does not issue unnecessary queries.
5. Wraps steps 2-3 in an `ActiveRecord::Base.transaction`.
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
`chatable_id` is not unique and can be a DM or Category, which could
potentially be a security issue if there was a category with the same ID
than a DM. Luckily, due to differences in the serializer this couldn't
cause an actual security issue but only a 500 error.
Given the specificity of this codepath, a test has been added to ensure
we never end up returning the wrong data.
Any user with assign permission could unassign/assign topics/posts they
cannot see (private categories, PMs they're not part of). This is
treated as a bug and not security due to the high status assigners
already have and the limited impact the action has. You would get a 200
and `{ success: true }` response which doesn't leak any important
information. So in practice it could only allow a high trusted user to
be disruptive.
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
Prior to this fix destroying a query would attempt to create a query with the provided id, hide it, and actually fail name validation. This fix simplifies the logic and just disables hiding default queries as it wasn't working anyways (the delete button will also be removed from the UI in this case) and will raise a 404 when trying to delete a non existing query.
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`.
Use frozen time and fixed event dates instead of `Time.now` to
make the color mapping system tests deterministic and prevent
flaky failures when events fall outside the visible calendar
range. Also update the event presence selector from
`.fc-daygrid-event-dot` to `.fc-daygrid-event`
The Message Card format that the Teams integration currently uses is no
longer supported in Teams. The new format is called Adaptive Card and
this commit switches our integration to use Adaptive Card format.
When `enable_simplified_category_creation` is disabled, the email fields
in category settings lose focus after every keystroke, making it
impossible to type an email address.
The legacy category editor wraps all tabs in a FormKit `<Form>`
component. The Form wrapper uses `{{#each (array @data)}}` which
destroys and recreates the entire component tree when `@data` changes
identity. Since the controller's `formData` getter returns a new object
(via `getProperties`) every time a model property changes, any two-way
binding mutation — including every keystroke in a `<TextField>` —
triggers a full teardown and rebuild of all form elements, destroying
the focused input.
This commit converts the email section inputs (`email_in`,
`email_in_allow_strangers`, `mailinglist_mirror`) from two-way model
bindings to use FormKit's data layer (`form.set` / `transientData`).
Edits to these fields no longer mutate the model, so `formData` isn't
invalidated and the form is not recreated.
This commit also passes `form` to the `category-email-in` and
`category-custom-settings` plugin outlets on the legacy page, matching
what the simplified page already does, so that plugins can adopt FormKit
as well.
https://meta.discourse.org/t/395609
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.
- Import the full `discourse/discourse-developer-docs` repository into
`docs/developer-guides/` using `git subtree add` with complete git
history preserved
- Move CI workflows (lint + publish) from the nested `.github/` to
root-level workflows scoped to `docs/developer-guides/**` path changes
- The [developer-docs repo](https://github.com/discourse/discourse-developer-docs) was archived.