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 (ON DELETE CASCADE, so deleting a comment removes its replies). - Both GraphQL and REST assemble the flat DB result into a
repliestree in memory before returning — there is no flat-list variant. - 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 is a hard
DELETEof the row, followed by arecount_commentspass on the parent idea. There is no soft-delete tombstone. - 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.
@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)). - 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 with thelo-emailIdeaMentiontemplate (only when the user has an email on record). - 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 require authentication: an anonymous caller is rejected (401 on REST, an authentication error on GraphQL) so the participant identity list cannot be scraped without a session.
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.
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, category_id (FK, NOT NULL, ON DELETE RESTRICT), title, description (sanitized HTML), status, vote_count_up, vote_count_down, comment_count, created_at, updated_at |
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, parent_id (nullable, self-ref), body, vote_count_up, vote_count_down, created_at, updated_at |
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. |
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 and, by cascade, its votes/comments/timeline/tag assignments |
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 |
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 |
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 | Auth required | 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 |
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/templates.rs | IdeaMention transactional email template |