Skip to main content

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 site when the system:ideas_hub_public feature flag is enabled.

Feature Flags

FlagCategoryEffect
system:ideas_hubsystemMaster kill-switch. When disabled, all Ideas Hub endpoints, pages, and WebSocket channels are unavailable regardless of auth status.
system:ideas_hub_publicsystemWhen enabled, the public marketing route (/hub/ideas) shows ideas and allows unauthenticated browsing. Submission and voting still require authentication.

Both flags are seeded by seed_default_feature_flags and gated in the WebSocket channel_feature_for function.

Architecture

Backend

  • GraphQL (apps/api/src/graphql/ideas.rs) — queries, mutations, and WebSocket subscriptions for ideas, comments, votes, tags, categories, and timeline.
  • REST (apps/api/src/routes/ideas.rs) — mirrors GraphQL operations under /v1/ideas and /v1/ideas/admin.
  • Database — all tables stored in PostgreSQL; see Database below.
  • WebSocket — real-time updates via ideas:{id} (single idea) and ideas:list (index) channels; see WebSocket Channels.
  • RSS — a public feed at /hub/ideas/feed.xml containing the 50 most recent open/planned/in_progress/testing ideas sorted by creation date.

Frontend

Browser
|-- Dashboard (/hub/ideas) → Next.js SSR + proxy routes → Rust GraphQL
|-- Public site (/hub/ideas) → Next.js SSR (marketing) → Rust GraphQL (public resolvers)
|-- RSS feed (/hub/ideas/feed.xml)→ Next.js route handler → Rust REST GET /v1/ideas/feed

Client-side hooks call Next.js proxy routes under apps/web/src/app/api/ideas/. SSR pages use serverGqlSSR() directly.

Idea Lifecycle

Status Values

Ideas move through a defined set of statuses. Only users with ideas:moderate_status can change the status.

StatusDescription
openDefault status on creation. Visible and accepting votes.
plannedAcknowledged by the team; on the roadmap.
in_progressActively being worked on.
testingFeature built, being tested before release.
completedFeature shipped.
closedDeclined or withdrawn; no longer accepting votes.

Status changes are recorded in the timeline automatically. An optional message can accompany each status change.

Voting

  • Each authenticated user can cast one vote per idea (up or down).
  • Vote counts (up_votes, down_votes) are stored as denormalized integer columns on the ideas table for fast read performance.
  • Changing a vote (e.g., from up to down) atomically updates both columns in a single transaction using the recount pattern: the old vote is removed, the new vote inserted, and both counters are recalculated via SELECT COUNT(*) ... WHERE vote = 'up' / 'down'.
  • Removing a vote decrements the appropriate counter.
  • The current user's vote is returned alongside every idea in the my_vote field so the UI can show the active state.

Comments

  • Comments support unlimited nesting via a parent_id self-referencing foreign key.
  • The GraphQL resolver assembles the flat DB result into a tree structure in memory before returning. REST returns a flat list with parent_id for the client to assemble.
  • Comment authors can edit or soft-delete their own comments. Admins with ideas:moderate_comment can delete any comment.
  • Soft-deleted comments retain their row but have their body replaced with [deleted] and is_deleted = true.

@Mentions

Comments support @mentioning other users who have interacted with the idea (the idea author, voters, and previous commenters).

Frontend:

  • The comment editor uses the Tiptap Mention extension.
  • Typing @ triggers an autocomplete dropdown populated by the ideaParticipants(ideaId, search) GraphQL query.
  • Inserted mentions are stored in comment HTML as <span data-mention-id="UUID" class="mention">@Name</span>.
  • Both the client-side DOMPurify sanitizer and the server-side ammonia sanitizer are configured to allow span elements with data-mention-id and class attributes so mention markup survives the round-trip.

Backend:

  • After a comment is created, the API parses all data-mention-id attribute values from the comment HTML.
  • Each UUID is validated against the users table. Invalid UUIDs are silently ignored.
  • For each valid mentioned user, the API checks user_notification_preferences via should_notify(db, user_id, "idea_mention") to determine the delivery channel.
  • Depending on the result, an in-app notification of type idea_mention is created and/or a transactional email is sent using the lo-email IdeaMention template.
  • Mention dispatch runs in a tokio::spawn fire-and-forget task — it never blocks the comment creation response.

Autocomplete endpoint:

The ideaParticipants(ideaId: UUID!, search: String) GraphQL query (and its REST mirror GET /v1/ideas/{id}/participants?search=) returns the union of the idea author, all voters, and all commenters, filtered by the optional search string. Requires authentication.

Timeline

Every idea maintains an ordered list of timeline entries recording:

  • Status changestype: "status_change" with from_status and to_status fields plus an optional message.
  • Content editstype: "edit" recording what was changed (title, description, category, tags).

Timeline entries are append-only and displayed chronologically on the idea detail page.

Tags

  • Tags are user-created; any authenticated user can create a new tag inline when submitting or editing an idea.
  • Tags are stored globally (not account-scoped) and shared across all ideas.
  • Admins with ideas:moderate_edit can delete any tag. Deleting a tag removes the idea_tags join rows but does not delete the ideas themselves.
  • Tags have a unique slug derived from the name for deduplication.

Categories

  • Categories are admin-managed only. Users can choose from existing categories when submitting an idea but cannot create new ones.
  • The ideas table has a category_id foreign key with ON DELETE RESTRICT — a category cannot be deleted while any idea references it.
  • Categories have a name, optional description, and color for UI display.

Database

Tables

TableDescription
ideasCore idea rows: id, user_id, title, description (Markdown), status, category_id (FK, nullable), up_votes, down_votes, is_pinned, created_at, updated_at
idea_votes(idea_id, user_id, vote) where vote is 'up' or 'down'. Unique on (idea_id, user_id).
idea_commentsid, idea_id, user_id, parent_id (nullable, self-ref), body, is_deleted, created_at, updated_at
idea_timelineid, idea_id, user_id, type (status_change or edit), data (JSONB for type-specific fields), created_at
idea_tagsid, name, slug. Global, not account-scoped.
idea_tag_assignments(idea_id, tag_id) join table
idea_categoriesid, name, description, color, sort_order, created_at, updated_at

API

GraphQL Queries

QueryPermissionDescription
ideas(filter: IdeaFilterInput)None (public when flag enabled; Auth otherwise)Paginated idea list with filters
idea(id: UUID!)None (public)Single idea with comments, votes, timeline, tags
ideaCategoriesNone (public)All categories
ideaTags(search: String)None (public)Tag list with optional search
myIdeaVote(ideaId: UUID!)AuthGuardCurrent user's vote for a specific idea
ideaParticipants(ideaId: UUID!, search: String)AuthGuardUsers who interacted with an idea (author, voters, commenters); used for @mention autocomplete

GraphQL Mutations

MutationPermissionDescription
createIdea(input: CreateIdeaInput!)AuthGuardSubmit a new idea
updateIdea(input: UpdateIdeaInput!)Own idea or ideas:moderate_editEdit title, description, category, tags
deleteIdea(id: UUID!)Own idea or ideas:moderate_deleteDelete an idea and all its votes/comments/timeline
changeIdeaStatus(id: UUID!, status: IdeaStatus!, message: String)ideas:moderate_statusChange status and append a timeline entry
voteIdea(ideaId: UUID!, vote: IdeaVote!)AuthGuardCast or change a vote (up or down)
removeIdeaVote(ideaId: UUID!)AuthGuardRemove the current user's vote
createIdeaComment(input: CreateIdeaCommentInput!)AuthGuardPost a comment (top-level or nested)
updateIdeaComment(id: UUID!, body: String!)Own commentEdit a comment body
deleteIdeaComment(id: UUID!)Own comment or ideas:moderate_commentSoft-delete a comment
createIdeaTag(name: String!)AuthGuardCreate a new global tag
deleteIdeaTag(id: UUID!)ideas:moderate_editDelete a tag
createIdeaCategory(input: CreateIdeaCategoryInput!)ideas:moderate_editCreate a new category
updateIdeaCategory(input: UpdateIdeaCategoryInput!)ideas:moderate_editEdit a category
deleteIdeaCategory(id: UUID!)ideas:moderate_editDelete a category (blocked if ideas reference it)
pinIdea(id: UUID!, pinned: Boolean!)ideas:moderate_editPin or unpin an idea

REST Endpoints

User-facing paths live under /v1/ideas. Admin paths live under /v1/ideas/admin.

MethodPathPermissionDescription
GET/v1/ideasNone (public)List ideas with query params for filter/sort/pagination
POST/v1/ideasAuthCreate an idea
GET/v1/ideas/feedNone (public)RSS feed data (also served as XML from /hub/ideas/feed.xml)
GET/v1/ideas/categoriesNone (public)List all categories
GET/v1/ideas/tagsNone (public)List all tags
GET/v1/ideas/{id}None (public)Get a single idea
PATCH/v1/ideas/{id}Own or ideas:moderate_editUpdate title, description, category, tags
DELETE/v1/ideas/{id}Own or ideas:moderate_deleteDelete an idea
POST/v1/ideas/{id}/votesAuthCast or change a vote
DELETE/v1/ideas/{id}/votesAuthRemove the current user's vote
GET/v1/ideas/{id}/participantsAuthList users who interacted with the idea (author, voters, commenters); supports ?search=
GET/v1/ideas/{id}/commentsNone (public)Flat list of comments with parent_id
POST/v1/ideas/{id}/commentsAuthPost a comment
PATCH/v1/ideas/{id}/comments/{comment_id}OwnEdit a comment
DELETE/v1/ideas/{id}/comments/{comment_id}Own or ideas:moderate_commentSoft-delete a comment
PATCH/v1/ideas/admin/{id}/statusideas:moderate_statusChange status
PATCH/v1/ideas/admin/{id}/pinideas:moderate_editPin / unpin
POST/v1/ideas/admin/categoriesideas:moderate_editCreate category
PATCH/v1/ideas/admin/categories/{id}ideas:moderate_editUpdate category
DELETE/v1/ideas/admin/categories/{id}ideas:moderate_editDelete category
DELETE/v1/ideas/admin/tags/{id}ideas:moderate_editDelete tag

Filter / Sort Options

ParameterValuesDescription
statusopen, planned, in_progress, completed, closedFilter by status
category_idUUIDFilter by category
tag_idsUUID listFilter to ideas with all specified tags
searchstringFull-text search on title and description
sortvotes, newest, oldest, trendingSort order
pageintegerPage number (1-based)
limitintegerItems per page (default 25, max 100)

WebSocket Channels

WebSocket channels are gated by the system:ideas_hub feature flag via channel_feature_for. Subscribing to either channel while the flag is disabled returns FEATURE_DISABLED.

ChannelPermissionDescription
ideas:listNone (public)Broadcasts IdeaCreated, IdeaUpdated, IdeaDeleted, IdeaVoteChanged events for the idea list view. Allows anonymous subscribers when the public flag is on.
ideas:{id}None (public)Per-idea channel broadcasting CommentCreated, CommentUpdated, CommentDeleted, IdeaStatusChanged, IdeaUpdated, TimelineEntryAdded events.

WebSocket messages follow the standard Lumio envelope: { "channel": "ideas:list", "event": "IdeaCreated", "data": { ... } }.

RSS Feed

The RSS feed is served at /hub/ideas/feed.xml by a Next.js route handler in the web app. It:

  • Returns a standard RSS 2.0 XML document.
  • Lists the 50 most recent ideas with status in open, planned, or in_progress, sorted by created_at DESC.
  • Each item includes: <title>, <link>, <description> (truncated Markdown), <pubDate>, and <category> elements.
  • Requires no authentication. Honors the system:ideas_hub kill-switch: if the flag is off, the route returns 404.

Permissions

Ideas Hub moderation permissions are admin-scope (not account-scope). They live in crates/lo-auth/src/rbac.rs::global.

PermissionDescription
ideas:moderate_readView the admin ideas management pages and moderation queue
ideas:moderate_statusChange status and append a status-change timeline entry
ideas:moderate_editEdit any idea's content, pin/unpin, manage categories and tags
ideas:moderate_deleteDelete any idea or comment
ideas:moderate_commentSoft-delete any comment

Key Files

FilePurpose
apps/api/src/graphql/ideas.rsGraphQL queries, mutations, and subscriptions
apps/api/src/routes/ideas.rsREST handlers
apps/api/src/db/ideas.rsDB operations for ideas, votes, comments, timeline, tags, categories
apps/web/src/app/api/ideas/Next.js proxy routes
apps/web/src/app/(app)/hub/ideas/Dashboard Ideas Hub pages
apps/web/src/app/(marketing)/hub/ideas/Public Ideas Hub pages
apps/admin/src/app/(admin)/ideas/Admin ideas management pages
crates/lo-auth/src/rbac.rsModeration permission constants
crates/lo-websocket/src/gate.rschannel_gate_for / channel_feature_for for ideas:* channels
crates/lo-email/IdeaMention transactional email template