Ideas Hub
Overview
The Ideas Hub is a community-facing feature request and feedback system built into Lumio. It serves as a global (not account-scoped) space where users can submit ideas, vote on existing ones, leave comments, and track progress through status changes. The hub is accessible both from the authenticated dashboard (/hub/ideas) and from the public marketing route (/ideas) when the system:ideas_hub_public feature flag is enabled.
Ideas Hub permissions are user-scoped, not account-scoped: they come from the user's global role (user_roles / user_role_permissions), so switching accounts does not change what a user may do in the hub.
Feature Flags
| Flag | Category | Effect |
|---|---|---|
system:ideas_hub | system | Master kill-switch. Every GraphQL resolver and REST handler calls a flag check first; when the flag is off, GraphQL returns "Ideas Hub is disabled" and REST returns 404. The dashboard route group is wrapped in FeatureRouteGate, and the WebSocket ideas channel family is gated on it via channel_feature_for. |
system:ideas_hub_public | system | The public kill-switch, enforced server-side on all three protocols (not only in the web UI). Required - in addition to system:ideas_hub - for every anonymous read on REST, GraphQL, and the WebSocket ideas channel, plus the public marketing pages at /ideas and the Atom feed at /ideas/feed.xml (which notFound() when either flag is off). Authenticated dashboard users reach reads on the master flag alone. Submission, voting, and commenting still require authentication. |
Both flags are seeded by migration 20260506000012_seed_ideas_feature_flags with enabled = true and default_for_accounts = true. They are infrastructure-category (system) flags, so they carry no plan_features rows and never appear on pricing cards.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/ideas.rs) - queries and mutations for ideas, comments, votes, voters, tags, categories, and timeline. There are no Ideas Hub GraphQL subscriptions; live updates are published to the WebSocket gateway instead. - REST (
apps/api/src/routes/ideas.rs) - mirrors the GraphQL operations under/v1/ideas. Admin operations live on the same prefix (for example/v1/ideas/categories), not under a separate/v1/ideas/adminnamespace. - Database - all tables stored in PostgreSQL; see Database below.
- WebSocket - real-time updates via
ideas:{id}(single idea) andideas:list(index) channels; see WebSocket Channels. - Atom feed - a public feed at
/ideas/feed.xmlserved by the web app; see Atom Feed.
Frontend
Browser
|-- Dashboard (/hub/ideas) → Next.js SSR + proxy routes → Rust GraphQL
|-- Public site (/ideas) → Next.js SSR (marketing) → Rust GraphQL (public resolvers)
|-- Atom feed (/ideas/feed.xml)→ Next.js route handler → Rust GraphQL (serverGqlSSR)
|-- Admin (/hub/ideas) → Next.js admin app → Rust GraphQL / REST
Client-side code calls the catch-all Next.js proxy route apps/web/src/app/api/ideas/[[...path]]/route.ts. SSR pages use serverGqlSSR() directly.
Submitting and editing an idea share one component - apps/web/src/app/(main)/(app)/hub/ideas/idea-wizard.tsx, a three-step FullscreenWizard (@lumio/ui) switched between create and edit by its editing prop. Create is embedded in the index (/hub/ideas); edit renders it on its own route (/hub/ideas/{id}/edit). A failed submission surfaces the error both as a toast and as an inline banner in the wizard rather than silently leaving the form open.
Idea Lifecycle
Status Values
Ideas move through a fixed allowlist of statuses. Changing the status requires ideas:moderate_status (held either as a user-role permission or as an admin-role permission). Any value outside the allowlist is rejected with "Invalid status".
| Status | Description |
|---|---|
open | Default status on creation. Visible and accepting votes. |
planned | Acknowledged by the team; on the roadmap. |
in_progress | Actively being worked on. |
testing | Feature built, being tested before release. |
completed | Feature shipped. |
closed | Declined or withdrawn. |
Every status change appends a status_changed timeline entry carrying the previous and new status. The status-change operation takes no free-text message.
Voting
- Each authenticated user with
ideas:votecan cast one vote per idea.idea_votesis keyed on(idea_id, user_id), so a second vote replaces the first. - The vote value is
upordown(columnvote_type). Any other value is rejected withvote_type must be "up" or "down". - Vote counts (
vote_count_up,vote_count_down) are denormalized integer columns on theideastable for fast reads. - Casting, changing, or removing a vote upserts/deletes the
idea_votesrow and then runsrecount_votes, which recomputes both counters fromidea_votes. - The current user's vote is returned alongside every idea as
myVote(GraphQL) /my_vote(REST) so the UI can show the active state. It isnullfor anonymous readers.
Comments
- Comments support unlimited nesting via a
parent_idself-referencing foreign key. Deleting a comment that has replies keeps the replies (see Deletion and tombstones). - Both GraphQL and REST assemble the flat DB result into a
repliestree in memory before returning - there is no flat-list variant. The flat read is capped at the oldest 500 comments per idea (MAX_IDEA_COMMENTS) so the anonymous read stays bounded, and all comment authors are resolved in one batched query rather than one per comment. - Comment bodies are HTML, sanitized server-side with
lo_common::sanitize_html(ammonia). Bodies larger than 10 000 bytes are rejected. - Editing a comment requires
ideas:comment_editand ownership. Deleting requiresideas:comment_deleteon your own comment, orideas:moderate_commentfor anyone else's. - Deletion follows Deletion and tombstones.
comment_countis recounted after every delete and counts live comments only, not tombstones. - Comments and replies can be voted on with
ideas:comment_vote, mirroring idea voting exactly.idea_comment_votesis keyed on(comment_id, user_id)(ON DELETE CASCADE), so a second vote replaces the first and re-voting the active direction removes it (toggle-off). Counts (vote_count_up,vote_count_down) are denormalized columns onidea_comments, recomputed byrecount_comment_votesafter every mutation. Each comment carries the caller'smyVote/my_votein the comment tree,nullfor anonymous readers.
Deletion and tombstones
Deleting a post never takes other people's posts with it. The rules live in db::ideas (delete_idea, delete_comment, remove_user_content) and run identically for GraphQL, REST and profile deletion:
- Comment (author or moderator): with replies, it becomes a tombstone - the row stays so the thread keeps its shape, while its
body, its author link and its own votes go. Without replies it is deleted outright. Replies to a tombstoned comment are still allowed. - Idea (author or moderator): with comments by anyone other than its author, it becomes a tombstone -
titleanddescriptionare emptied, the author link, its votes, tag assignments and timeline go, and the other people's comments stay. The author's own comments under it are deleted, except those someone else replied to, which become tombstones. Without comments by others the idea is deleted outright with everything under it. - Profile deletion (
db::admin::delete_user_rows): the person's ideas and comments follow the same two rules in the same transaction, their idea and comment votes go, and every counter they fed is recounted. - A tombstone that loses its last reply (or an idea tombstone its last comment) is deleted as well, so tombstones never outlive the content they hold up.
- A database CHECK constraint guarantees that a tombstone carries no author and no text.
A tombstone reads as deleted: true with author: null and an empty title/description (idea) or body (comment) on every read path, for every caller: ideas/GET /v1/ideas (including search and the admin list), idea/GET /v1/ideas/{id}, ideaComments/GET /v1/ideas/{id}/comments. Its voter list and timeline are empty. Live ideas and comments carry deleted: false. Writes to a tombstone are refused with the same message on both protocols: Idea was deleted for editing, voting on, commenting on or changing the status of an idea tombstone, Comment was deleted for editing or voting on a comment tombstone (409 CONFLICT on REST). Since a tombstone has no author, only a moderator can delete it, and it stays while replies hang off it. The delete mutations keep their result (true / { "ok": true }); refetch to see whether the post was removed or became a tombstone.
@Mentions
Comments support @mentioning other users who have interacted with the idea (the idea author, voters, and previous commenters).
Frontend:
- The comment editor is the shared
RichTextEditorcomponent, built on Tiptap with the Mention extension. The mention node and its suggestion popup live inapps/web/src/components/rich-text-editor-mention.ts, covered byapps/web/__tests__/components/rich-text-editor-mention.test.tsx. - Typing
@triggers an autocomplete dropdown backed by the participants endpoint (ideaParticipants(ideaId, search)). The participant list requires a signed-in user session or personal API key; embed and popout tokens cannot read it. - ProseMirror must resolve to a single copy of
prosemirror-modelandprosemirror-view. Tiptap builds the mentionFragmentwith@tiptap/pm/modeland hands it toprosemirror-transform; if the two load different copies, theinstanceofcheck fails and inserting a mention throwsRangeError: Can not convert <mention, " "> to a Fragmentinside the click handler - the dropdown appears but selecting an entry does nothing. The rootpackage.jsonpins both throughpnpm.overrides; keep those pins in step with@tiptap/pmwhen bumping Tiptap. - Inserted mentions are stored in comment HTML as
<span data-type="mention" data-id="UUID" data-label="Name" data-mention-id="UUID" class="mention">@Name</span>.data-type/data-id/data-labelare Tiptap's canonical mention attributes - the Mention extension'sparseHTML()only re-parsesspan[data-type="mention"]and recovers the id/label fromdata-id/data-label. Emitting onlydata-mention-id(the original markup) meant re-loading a saved comment into the editor - e.g. when editing it - degraded every mention back to plain text and dropped the id, so the mention lost its styling, its link, and could no longer be resolved (ZAF-531).data-mention-idis retained alongside them for the backend regex extraction. - Both sanitizers must allow all five attributes for the markup to survive the save round-trip: the client-side DOMPurify allowlist in
shared/ui/src/sanitize-html.tsand the server-side ammonia sanitizer (crates/lo-common/src/sanitize.rs), which permitsspanelements withdata-type,data-id,data-label,data-mention-id, andclass.
Backend (apps/api/src/services/idea_mentions.rs):
- After a comment is created, the API regex-parses every
data-mention-idattribute value from the sanitized comment HTML. - The author's own id is skipped and duplicates are collapsed.
- Remaining ids are validated against the participants of that idea (idea author ∪ commenters ∪ voters). A well-formed UUID that is not a participant is silently dropped.
- For each surviving user, the API calls
should_notify(db, user_id, "idea_mention")to resolve the delivery channel. - Depending on the result, an in-app notification of type
idea_mentionis created and/or a transactional email is sent withlo-emailNoticeKind::IdeaMention(only when the user has an email on record). Its footer links to notification settings becauseidea_mentionis configurable. - Mention dispatch runs in a
tokio::spawnfire-and-forget task - it never blocks the comment creation response.
Autocomplete endpoint:
ideaParticipants(ideaId: UUID!, search: String) (GraphQL) and GET /v1/ideas/{id}/participants?search= (REST) return the union of the idea author, all voters, and all commenters, filtered by the optional search string (bounded to 10 rows). @mention autocomplete is a dashboard-only action, so both accept only a signed-in user: a dashboard session or the user's own API key (lm_usr_*). An anonymous caller gets Authentication required (401 on REST, UNAUTHENTICATED on GraphQL). Every other credential - overlay, shared-overlay, widget, extension and popout tokens, service, developer and system keys - gets This endpoint requires a logged-in user session (403 on REST, FORBIDDEN on GraphQL).
Timeline
Every idea maintains an append-only list of timeline entries. Each row records an action, an optional old_value, an optional new_value, and the acting user (actor, nullable once the user is deleted).
action | old_value → new_value |
|---|---|
status_changed | Previous status → new status. Also written on creation as null → open. |
title_changed | Previous title → new title |
description_changed | Previous description → new description |
category_changed | Previous category label → new category label |
Timeline entries are displayed chronologically (created_at ASC) on the idea detail page. Tag changes are not recorded on the timeline.
The previous and new text of title_changed and description_changed entries are returned only to the idea's author and Ideas staff (ideas:moderate_read). Everyone else, including anonymous visitors, gets null for both values. Status and category values are public. An author's or actor's login is likewise returned only to staff and to the user themself. See REST API → Ideas Hub.
Sharing
Both the dashboard detail page and the public detail page expose a Share action that copies the idea's public URL (/ideas/{id}) to the clipboard via navigator.clipboard. This is a purely client-side action - no share token or backend call is involved; the target is the public marketing page, gated by the system:ideas_hub_public feature flag. Both pages show an inline confirmation (the icon flips to a check for two seconds).
Tags
- Tags are user-created; any user holding
ideas:createcan create a tag inline when submitting or editing an idea. - Tags are stored globally (not account-scoped) and shared across all ideas.
- Tag names are trimmed and must be 1–50 characters.
nameisUNIQUE, and creating an existing tag returns the existing row rather than erroring. - Deleting a tag requires the admin permission
ideas:delete. Deleting removes theidea_tag_assignmentsrows via cascade but does not delete the ideas themselves. - An idea carries at most 20 tags.
Categories
- Categories are admin-managed only. Users choose from existing categories when submitting an idea but cannot create new ones.
ideas.category_idisNOT NULLwithON DELETE RESTRICT- every idea has a category, and a category cannot be deleted while any idea references it (the API surfaces this as"Cannot delete category: ideas still reference it").- A category has a machine
name(unique), a human-facinglabel, an optionalcolor, and asort_order. There is no description field. - Eight categories ship seeded:
overlay,chat,bot,integration,music,automation,ui,other.
Database
Tables
| Table | Description |
|---|---|
ideas | Core idea rows: id, author_id (NULL on a tombstone), category_id (FK, NOT NULL, ON DELETE RESTRICT), title, description (sanitized HTML), status, vote_count_up, vote_count_down, comment_count (live comments only), created_at, updated_at, deleted_at (set on a tombstone) |
idea_votes | idea_id, user_id, vote_type ('up' or 'down'), created_at. Primary key (idea_id, user_id). |
idea_comments | id, idea_id, author_id (NULL on a tombstone), parent_id (nullable, self-ref), body, vote_count_up, vote_count_down, created_at, updated_at, deleted_at (set on a tombstone) |
idea_comment_votes | comment_id, user_id, vote_type ('up' or 'down'), created_at. Primary key (comment_id, user_id), both FKs ON DELETE CASCADE. |
idea_timeline | id, idea_id, actor_id (nullable, ON DELETE SET NULL), action, old_value, new_value, created_at |
idea_tags | id, name (unique), created_by, created_at. Global, not account-scoped. |
idea_tag_assignments | (idea_id, tag_id) join table |
idea_categories | id, name (unique), label, color, sort_order, created_at |
API
GraphQL Queries
Ideas Hub reads are public-flag gated for anonymous callers: every read requires system:ideas_hub (master), and an anonymous caller additionally requires system:ideas_hub_public. Authenticated users reach the dashboard on the master flag alone. This enforces the public kill-switch server-side (identically on REST and WebSocket), not just in the web UI. ideaParticipants is the exception - it requires authentication.
| Query | Permission | Description |
|---|---|---|
ideas(filter: IdeaFilterInput, sort: IdeaSortInput, limit: Int! = 20, offset: Int! = 0) | Flag (public flag if anonymous) | Paginated idea list; returns GqlIdeaConnection { items, total }. limit clamps to 1–100. Authors, categories, tags and the caller's votes for the whole page are resolved in one batched query each (no per-idea lookups); REST GET /v1/ideas does the same. |
idea(id: UUID!) | Flag (public flag if anonymous) | Single idea (GqlIdea), or null when not found |
ideaComments(ideaId: UUID!) | Flag (public flag if anonymous) | Comment tree for an idea |
ideaCategories | Flag (public flag if anonymous) | All categories |
ideaTags(search: String) | Flag (public flag if anonymous) | Tag list with optional search |
ideaVoters(ideaId: UUID!) | Flag (public flag if anonymous) | Who voted, and which way (GqlIdeaVoter); bounded to 500 rows |
ideaParticipants(ideaId: UUID!, search: String) | Auth required | Users who interacted with an idea (author, voters, commenters); used for @mention autocomplete |
ideaTimeline(ideaId: UUID!) | Flag (public flag if anonymous) | Timeline entries for an idea, oldest first |
There is no myIdeaVote query - the caller's own vote is returned inline on GqlIdea.myVote.
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
createIdea(input: CreateIdeaInput!) | ideas:create | Submit a new idea; writes a status_changed (null → open) timeline entry |
updateIdea(id: UUID!, input: UpdateIdeaInput!) | ideas:edit (own) or ideas:moderate_edit (any) | Edit title, description, category, tags |
deleteIdea(id: UUID!) | ideas:delete (own) or ideas:moderate_delete (any) | Delete an idea; a tombstone while others' comments hang off it (Deletion and tombstones) |
updateIdeaStatus(id: UUID!, status: String!) | ideas:moderate_status | Change status and append a timeline entry |
voteIdea(id: UUID!, voteType: String!) | ideas:vote | Cast or change a vote ("up" or "down") |
removeVote(id: UUID!) | ideas:vote | Remove the current user's vote |
createIdeaComment(input: CreateIdeaCommentInput!) | ideas:comment_create | Post a comment (top-level or nested) |
updateIdeaComment(id: UUID!, body: String!) | ideas:comment_edit + ownership | Edit a comment body |
deleteIdeaComment(id: UUID!) | ideas:comment_delete (own) or ideas:moderate_comment (any) | Delete a comment; a tombstone while it has replies |
voteIdeaComment(id: UUID!, voteType: String!) | ideas:comment_vote | Cast or change a comment vote ("up" or "down") |
removeIdeaCommentVote(id: UUID!) | ideas:comment_vote | Remove the current user's comment vote |
createIdeaTag(name: String!) | ideas:create | Create (or return) a global tag |
deleteIdeaTag(id: UUID!) | admin ideas:delete | Delete a tag |
createIdeaCategory(input: CreateIdeaCategoryInput!) | admin ideas:edit | Create a new category |
updateIdeaCategory(id: UUID!, input: UpdateIdeaCategoryInput!) | admin ideas:edit | Edit a category |
deleteIdeaCategory(id: UUID!) | admin ideas:delete | Delete a category (blocked if ideas reference it) |
The four category/tag admin mutations are guarded by AdminPermissionGuard, so they check the caller's admin-role permissions. The moderation checks (ideas:moderate_*) accept the permission from either the user role or an admin role.
Pinning is not implemented - there is no pinIdea mutation, no pin endpoint, and no is_pinned column.
REST Endpoints
All Ideas Hub REST paths live under /v1/ideas, including the moderator/admin ones.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/ideas | Flag only | List ideas with query params for filter/sort/pagination |
POST | /v1/ideas | ideas:create | Create an idea |
GET | /v1/ideas/categories | Flag (public if anon) | List all categories |
GET | /v1/ideas/tags | Flag (public if anon) | List all tags |
POST | /v1/ideas/tags | ideas:create | Create (or return) a tag |
GET | /v1/ideas/{id} | Flag (public if anon) | Get a single idea plus its timeline ({ idea, timeline }) |
PATCH | /v1/ideas/{id} | ideas:edit (own) or ideas:moderate_edit | Update title, description, category, tags |
DELETE | /v1/ideas/{id} | ideas:delete (own) or ideas:moderate_delete | Delete an idea; a tombstone while others' comments hang off it |
POST | /v1/ideas/{id}/vote | ideas:vote | Cast or change a vote |
DELETE | /v1/ideas/{id}/vote | ideas:vote | Remove the current user's vote |
GET | /v1/ideas/{id}/voters | Flag (public if anon) | List voters and their vote type; bounded to 500 rows |
GET | /v1/ideas/{id}/participants | Signed-in user (session or lm_usr_*) | List users who interacted with the idea; supports ?search= |
GET | /v1/ideas/{id}/comments | Flag (public if anon) | Comment tree with nested replies |
POST | /v1/ideas/{id}/comments | ideas:comment_create | Post a comment |
PATCH | /v1/ideas/comments/{id} | ideas:comment_edit + ownership | Edit a comment |
DELETE | /v1/ideas/comments/{id} | ideas:comment_delete (own) or ideas:moderate_comment | Delete a comment; a tombstone while it has replies |
POST | /v1/ideas/comments/{id}/vote | ideas:comment_vote | Cast or change a comment vote |
DELETE | /v1/ideas/comments/{id}/vote | ideas:comment_vote | Remove the current user's comment vote |
PATCH | /v1/ideas/{id}/status | ideas:moderate_status | Change status |
POST | /v1/ideas/categories | admin ideas:edit | Create category |
PATCH | /v1/ideas/categories/{id} | admin ideas:edit | Update category |
DELETE | /v1/ideas/categories/{id} | admin ideas:delete | Delete category |
DELETE | /v1/ideas/tags/{id} | admin ideas:delete | Delete tag |
REST has no standalone timeline endpoint - the timeline ships inside GET /v1/ideas/{id}, mirroring GraphQL's ideaTimeline.
Filter / Sort Options
GraphQL takes these as IdeaFilterInput / IdeaSortInput (camelCase); REST takes them as query parameters (snake_case). The values are identical.
| Parameter | Values | Description |
|---|---|---|
status | open, planned, in_progress, testing, completed, closed | Filter by status |
category_id / categoryId | UUID | Filter by category |
tag_ids / tagIds | UUID list (REST: comma-separated) | Filter to ideas carrying the specified tags |
author_id / authorId | UUID | Filter by author |
search | string | Search on title and description |
sort | newest, most_voted, most_commented, recently_updated | Sort order (default newest) |
limit | integer | Page size (default 20, clamped to 1–100) |
offset | integer | Row offset (default 0) |
Validation Limits
Identical on both protocols:
| Field | Limit |
|---|---|
| Idea title | 200 characters |
| Idea description | 50 000 bytes |
| Tags per idea | 20 |
| Tag name | 1–50 characters |
| Comment body | 10 000 bytes |
WebSocket Channels
The ideas channel family is gated by channel_feature_for("ideas") = "system:ideas_hub" (master, enforced for every subscriber) and channel_gate_for("ideas") = PublicWhenFeature("system:ideas_hub_public") - the public kill-switch, which feature_check enforces for anonymous subscribers. So an anonymous client is refused when either flag is off, while an authenticated client subscribes on the master flag alone (matching the REST/GraphQL rule). Traffic is server-push only; clients never publish on these channels.
| Channel | Gate | Description |
|---|---|---|
ideas:list | Public when system:ideas_hub_public | Index channel. Carries idea:created. |
ideas:{id} | Public when system:ideas_hub_public | Per-idea channel. Carries idea:updated, idea:voted, idea:comment, idea:comment_voted, idea:status_changed. |
Payloads are thin change notifications, not full entities - for example { "type": "idea:comment", "idea_id": "…", "comment_id": "…" }, { "type": "idea:comment_voted", "idea_id": "…", "comment_id": "…" }, or { "type": "idea:status_changed", "idea_id": "…", "status": "planned" }. Clients refetch on receipt. idea:comment_voted is emitted for symmetry with idea:voted; there is no consumer today.
Atom Feed
The feed is served at /ideas/feed.xml by a Next.js route handler in the web app (apps/web/src/app/(main)/(marketing)/ideas/feed.xml/route.ts). It:
- Returns an Atom 1.0 document (
application/atom+xml), cached for 300 seconds. - Lists the 50 newest ideas (
sort: NEWEST,limit: 50) regardless of status. - Emits per entry:
<title>,<link>,<id>,<updated>,<published>,<author>, a<category>for the category label, a second<category term="…" scheme="status">, and a<summary type="text">carrying the vote/comment counts plus the first 500 characters of the tag-stripped description. - Requires no authentication, and sources its data through
serverGqlSSR()- not a dedicated REST endpoint. - Requires both
system:ideas_hubandsystem:ideas_hub_public; if either is off it returns a 404 XML body.
Permissions
Ideas Hub uses two permission scopes, both defined in crates/lo-auth/src/rbac.rs.
User-scope (rbac::user) - granted through global user roles, seeded by migration 20260506000002_seed_default_user_roles:
| Permission | Description |
|---|---|
ideas:read | Read ideas |
ideas:create | Submit ideas and create tags |
ideas:edit | Edit your own idea |
ideas:delete | Delete your own idea |
ideas:vote | Cast and remove votes |
ideas:comment_read | Read comments |
ideas:comment_create | Post comments |
ideas:comment_edit | Edit your own comment |
ideas:comment_delete | Delete your own comment |
ideas:comment_vote | Cast and remove comment votes |
ideas:read and ideas:comment_read exist in the registry, but no read path enforces them today - reads are gated on the feature flag alone.
Three user roles ship seeded: Member (default - every permission above), Restricted (ideas:read, ideas:comment_read), and Moderator (Member's set plus the four ideas:moderate_* permissions below).
Admin-scope (rbac::global) - seeded onto the system_admin admin role by migration 20260506000011_seed_ideas_admin_permissions:
| Permission | Description |
|---|---|
ideas:moderate_read | View the admin ideas management pages and moderation queue |
ideas:moderate_status | Change the status of any idea |
ideas:moderate_edit | Edit any idea's content; gates the admin category/tag management pages |
ideas:moderate_delete | Delete any idea |
ideas:moderate_comment | Delete any comment |
Moderation checks pass when the permission is present on either the user role or an admin role. The category/tag write endpoints are the exception: they require the admin-scope strings ideas:edit / ideas:delete.
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/ideas.rs | GraphQL queries and mutations |
apps/api/src/routes/ideas.rs | REST handlers |
apps/api/src/db/ideas.rs | DB operations for ideas, votes, comments, timeline, tags, categories |
apps/api/src/services/idea_mentions.rs | @mention parsing, notification, and email dispatch |
apps/web/src/app/api/ideas/[[...path]]/route.ts | Next.js proxy route |
apps/web/src/app/(main)/(app)/hub/ideas/ | Dashboard Ideas Hub pages |
apps/web/src/app/(main)/(marketing)/ideas/ | Public Ideas Hub pages and Atom feed |
apps/admin/src/app/(admin)/hub/ideas/ | Admin ideas, category, and tag management pages |
crates/lo-auth/src/rbac.rs | User-scope and admin-scope permission constants |
crates/lo-common/src/sanitize.rs | Shared ammonia HTML sanitizer (allows mention spans) |
crates/lo-websocket/src/gate.rs | channel_gate_for / channel_feature_for for ideas:* channels |
crates/lo-email/src/notices.rs | IdeaMention email copy in both languages |