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
| Flag | Category | Effect |
|---|---|---|
system:ideas_hub | system | Master kill-switch. When disabled, all Ideas Hub endpoints, pages, and WebSocket channels are unavailable regardless of auth status. |
system:ideas_hub_public | system | When 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/ideasand/v1/ideas/admin. - 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. - RSS — a public feed at
/hub/ideas/feed.xmlcontaining 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.
| 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; 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 theideastable for fast read performance. - Changing a vote (e.g., from up to down) atomically updates both columns in a single transaction using the
recountpattern: the old vote is removed, the new vote inserted, and both counters are recalculated viaSELECT 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_votefield so the UI can show the active state.
Comments
- Comments support unlimited nesting via a
parent_idself-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_idfor the client to assemble. - Comment authors can edit or soft-delete their own comments. Admins with
ideas:moderate_commentcan delete any comment. - Soft-deleted comments retain their row but have their body replaced with
[deleted]andis_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 theideaParticipants(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
spanelements withdata-mention-idandclassattributes so mention markup survives the round-trip.
Backend:
- After a comment is created, the API parses all
data-mention-idattribute values from the comment HTML. - Each UUID is validated against the
userstable. Invalid UUIDs are silently ignored. - For each valid mentioned user, the API checks
user_notification_preferencesviashould_notify(db, user_id, "idea_mention")to determine the delivery channel. - Depending on the result, an in-app notification of type
idea_mentionis created and/or a transactional email is sent using thelo-emailIdeaMentiontemplate. - Mention dispatch runs in a
tokio::spawnfire-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 changes —
type: "status_change"withfrom_statusandto_statusfields plus an optional message. - Content edits —
type: "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_editcan delete any tag. Deleting a tag removes theidea_tagsjoin rows but does not delete the ideas themselves. - Tags have a unique
slugderived 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
ideastable has acategory_idforeign key withON DELETE RESTRICT— a category cannot be deleted while any idea references it. - Categories have a
name, optionaldescription, andcolorfor UI display.
Database
Tables
| Table | Description |
|---|---|
ideas | Core 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_comments | id, idea_id, user_id, parent_id (nullable, self-ref), body, is_deleted, created_at, updated_at |
idea_timeline | id, idea_id, user_id, type (status_change or edit), data (JSONB for type-specific fields), created_at |
idea_tags | id, name, slug. Global, not account-scoped. |
idea_tag_assignments | (idea_id, tag_id) join table |
idea_categories | id, name, description, color, sort_order, created_at, updated_at |
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
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 |
ideaCategories | None (public) | All categories |
ideaTags(search: String) | None (public) | Tag list with optional search |
myIdeaVote(ideaId: UUID!) | AuthGuard | Current user's vote for a specific idea |
ideaParticipants(ideaId: UUID!, search: String) | AuthGuard | Users who interacted with an idea (author, voters, commenters); used for @mention autocomplete |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
createIdea(input: CreateIdeaInput!) | AuthGuard | Submit a new idea |
updateIdea(input: UpdateIdeaInput!) | Own idea or ideas:moderate_edit | Edit title, description, category, tags |
deleteIdea(id: UUID!) | Own idea or ideas:moderate_delete | Delete an idea and all its votes/comments/timeline |
changeIdeaStatus(id: UUID!, status: IdeaStatus!, message: String) | ideas:moderate_status | Change status and append a timeline entry |
voteIdea(ideaId: UUID!, vote: IdeaVote!) | AuthGuard | Cast or change a vote (up or down) |
removeIdeaVote(ideaId: UUID!) | AuthGuard | Remove the current user's vote |
createIdeaComment(input: CreateIdeaCommentInput!) | AuthGuard | Post a comment (top-level or nested) |
updateIdeaComment(id: UUID!, body: String!) | Own comment | Edit a comment body |
deleteIdeaComment(id: UUID!) | Own comment or ideas:moderate_comment | Soft-delete a comment |
createIdeaTag(name: String!) | AuthGuard | Create a new global tag |
deleteIdeaTag(id: UUID!) | ideas:moderate_edit | Delete a tag |
createIdeaCategory(input: CreateIdeaCategoryInput!) | ideas:moderate_edit | Create a new category |
updateIdeaCategory(input: UpdateIdeaCategoryInput!) | ideas:moderate_edit | Edit a category |
deleteIdeaCategory(id: UUID!) | ideas:moderate_edit | Delete a category (blocked if ideas reference it) |
pinIdea(id: UUID!, pinned: Boolean!) | ideas:moderate_edit | Pin or unpin an idea |
REST Endpoints
User-facing paths live under /v1/ideas. Admin paths live under /v1/ideas/admin.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/ideas | None (public) | List ideas with query params for filter/sort/pagination |
POST | /v1/ideas | Auth | Create an idea |
GET | /v1/ideas/feed | None (public) | RSS feed data (also served as XML from /hub/ideas/feed.xml) |
GET | /v1/ideas/categories | None (public) | List all categories |
GET | /v1/ideas/tags | None (public) | List all tags |
GET | /v1/ideas/{id} | None (public) | Get a single idea |
PATCH | /v1/ideas/{id} | Own or ideas:moderate_edit | Update title, description, category, tags |
DELETE | /v1/ideas/{id} | Own or ideas:moderate_delete | Delete an idea |
POST | /v1/ideas/{id}/votes | Auth | Cast or change a vote |
DELETE | /v1/ideas/{id}/votes | Auth | Remove the current user's vote |
GET | /v1/ideas/{id}/participants | Auth | List users who interacted with the idea (author, voters, commenters); supports ?search= |
GET | /v1/ideas/{id}/comments | None (public) | Flat list of comments with parent_id |
POST | /v1/ideas/{id}/comments | Auth | Post a comment |
PATCH | /v1/ideas/{id}/comments/{comment_id} | Own | Edit a comment |
DELETE | /v1/ideas/{id}/comments/{comment_id} | Own or ideas:moderate_comment | Soft-delete a comment |
PATCH | /v1/ideas/admin/{id}/status | ideas:moderate_status | Change status |
PATCH | /v1/ideas/admin/{id}/pin | ideas:moderate_edit | Pin / unpin |
POST | /v1/ideas/admin/categories | ideas:moderate_edit | Create category |
PATCH | /v1/ideas/admin/categories/{id} | ideas:moderate_edit | Update category |
DELETE | /v1/ideas/admin/categories/{id} | ideas:moderate_edit | Delete category |
DELETE | /v1/ideas/admin/tags/{id} | ideas:moderate_edit | Delete tag |
Filter / Sort Options
| Parameter | Values | Description |
|---|---|---|
status | open, planned, in_progress, completed, closed | Filter by status |
category_id | UUID | Filter by category |
tag_ids | UUID list | Filter to ideas with all specified tags |
search | string | Full-text search on title and description |
sort | votes, newest, oldest, trending | Sort order |
page | integer | Page number (1-based) |
limit | integer | Items 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.
| Channel | Permission | Description |
|---|---|---|
ideas:list | None (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
statusinopen,planned, orin_progress, sorted bycreated_at DESC. - Each item includes:
<title>,<link>,<description>(truncated Markdown),<pubDate>, and<category>elements. - Requires no authentication. Honors the
system:ideas_hubkill-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.
| Permission | Description |
|---|---|
ideas:moderate_read | View the admin ideas management pages and moderation queue |
ideas:moderate_status | Change status and append a status-change timeline entry |
ideas:moderate_edit | Edit any idea's content, pin/unpin, manage categories and tags |
ideas:moderate_delete | Delete any idea or comment |
ideas:moderate_comment | Soft-delete any comment |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/ideas.rs | GraphQL queries, mutations, and subscriptions |
apps/api/src/routes/ideas.rs | REST handlers |
apps/api/src/db/ideas.rs | DB 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.rs | Moderation permission constants |
crates/lo-websocket/src/gate.rs | channel_gate_for / channel_feature_for for ideas:* channels |
crates/lo-email/ | IdeaMention transactional email template |