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 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

FlagCategoryEffect
system:ideas_hubsystemMaster 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_publicsystemThe 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/admin namespace.
  • 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.
  • Atom feed — a public feed at /ideas/feed.xml served 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".

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.

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:vote can cast one vote per idea. idea_votes is keyed on (idea_id, user_id), so a second vote replaces the first.
  • The vote value is up or down (column vote_type). Any other value is rejected with vote_type must be "up" or "down".
  • Vote counts (vote_count_up, vote_count_down) are denormalized integer columns on the ideas table for fast reads.
  • Casting, changing, or removing a vote upserts/deletes the idea_votes row and then runs recount_votes, which recomputes both counters from idea_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 is null for anonymous readers.

Comments

  • Comments support unlimited nesting via a parent_id self-referencing foreign key (ON DELETE CASCADE, so deleting a comment removes its replies).
  • Both GraphQL and REST assemble the flat DB result into a replies tree 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_edit and ownership. Deleting requires ideas:comment_delete on your own comment, or ideas:moderate_comment for anyone else's.
  • Deletion is a hard DELETE of the row, followed by a recount_comments pass 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_votes is 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 on idea_comments, recomputed by recount_comment_votes after every mutation. Each comment carries the caller's myVote / my_vote in the comment tree, null for 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 RichTextEditor component, built on Tiptap with the Mention extension. The mention node and its suggestion popup live in apps/web/src/components/rich-text-editor-mention.ts, covered by apps/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-model and prosemirror-view. Tiptap builds the mention Fragment with @tiptap/pm/model and hands it to prosemirror-transform; if the two load different copies, the instanceof check fails and inserting a mention throws RangeError: Can not convert <mention, " "> to a Fragment inside the click handler — the dropdown appears but selecting an entry does nothing. The root package.json pins both through pnpm.overrides; keep those pins in step with @tiptap/pm when 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-label are Tiptap's canonical mention attributes — the Mention extension's parseHTML() only re-parses span[data-type="mention"] and recovers the id/label from data-id/data-label. Emitting only data-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-id is 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.ts and the server-side ammonia sanitizer (crates/lo-common/src/sanitize.rs), which permits span elements with data-type, data-id, data-label, data-mention-id, and class.

Backend (apps/api/src/services/idea_mentions.rs):

  • After a comment is created, the API regex-parses every data-mention-id attribute 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_mention is created and/or a transactional email is sent with the lo-email IdeaMention template (only when the user has an email on record).
  • Mention dispatch runs in a tokio::spawn fire-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).

actionold_valuenew_value
status_changedPrevious status → new status. Also written on creation as nullopen.
title_changedPrevious title → new title
description_changedPrevious description → new description
category_changedPrevious 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:create can 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. name is UNIQUE, and creating an existing tag returns the existing row rather than erroring.
  • Deleting a tag requires the admin permission ideas:delete. Deleting removes the idea_tag_assignments rows 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_id is NOT NULL with ON 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-facing label, an optional color, and a sort_order. There is no description field.
  • Eight categories ship seeded: overlay, chat, bot, integration, music, automation, ui, other.

Database

Tables

TableDescription
ideasCore 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_votesidea_id, user_id, vote_type ('up' or 'down'), created_at. Primary key (idea_id, user_id).
idea_commentsid, idea_id, author_id, parent_id (nullable, self-ref), body, vote_count_up, vote_count_down, created_at, updated_at
idea_comment_votescomment_id, user_id, vote_type ('up' or 'down'), created_at. Primary key (comment_id, user_id), both FKs ON DELETE CASCADE.
idea_timelineid, idea_id, actor_id (nullable, ON DELETE SET NULL), action, old_value, new_value, created_at
idea_tagsid, name (unique), created_by, created_at. Global, not account-scoped.
idea_tag_assignments(idea_id, tag_id) join table
idea_categoriesid, 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.

QueryPermissionDescription
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
ideaCategoriesFlag (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 requiredUsers 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

MutationPermissionDescription
createIdea(input: CreateIdeaInput!)ideas:createSubmit a new idea; writes a status_changed (nullopen) 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_statusChange status and append a timeline entry
voteIdea(id: UUID!, voteType: String!)ideas:voteCast or change a vote ("up" or "down")
removeVote(id: UUID!)ideas:voteRemove the current user's vote
createIdeaComment(input: CreateIdeaCommentInput!)ideas:comment_createPost a comment (top-level or nested)
updateIdeaComment(id: UUID!, body: String!)ideas:comment_edit + ownershipEdit 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_voteCast or change a comment vote ("up" or "down")
removeIdeaCommentVote(id: UUID!)ideas:comment_voteRemove the current user's comment vote
createIdeaTag(name: String!)ideas:createCreate (or return) a global tag
deleteIdeaTag(id: UUID!)admin ideas:deleteDelete a tag
createIdeaCategory(input: CreateIdeaCategoryInput!)admin ideas:editCreate a new category
updateIdeaCategory(id: UUID!, input: UpdateIdeaCategoryInput!)admin ideas:editEdit a category
deleteIdeaCategory(id: UUID!)admin ideas:deleteDelete 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.

MethodPathPermissionDescription
GET/v1/ideasFlag onlyList ideas with query params for filter/sort/pagination
POST/v1/ideasideas:createCreate an idea
GET/v1/ideas/categoriesFlag (public if anon)List all categories
GET/v1/ideas/tagsFlag (public if anon)List all tags
POST/v1/ideas/tagsideas:createCreate (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_editUpdate title, description, category, tags
DELETE/v1/ideas/{id}ideas:delete (own) or ideas:moderate_deleteDelete an idea
POST/v1/ideas/{id}/voteideas:voteCast or change a vote
DELETE/v1/ideas/{id}/voteideas:voteRemove the current user's vote
GET/v1/ideas/{id}/votersFlag (public if anon)List voters and their vote type; bounded to 500 rows
GET/v1/ideas/{id}/participantsAuth requiredList users who interacted with the idea; supports ?search=
GET/v1/ideas/{id}/commentsFlag (public if anon)Comment tree with nested replies
POST/v1/ideas/{id}/commentsideas:comment_createPost a comment
PATCH/v1/ideas/comments/{id}ideas:comment_edit + ownershipEdit a comment
DELETE/v1/ideas/comments/{id}ideas:comment_delete (own) or ideas:moderate_commentDelete a comment
POST/v1/ideas/comments/{id}/voteideas:comment_voteCast or change a comment vote
DELETE/v1/ideas/comments/{id}/voteideas:comment_voteRemove the current user's comment vote
PATCH/v1/ideas/{id}/statusideas:moderate_statusChange status
POST/v1/ideas/categoriesadmin ideas:editCreate category
PATCH/v1/ideas/categories/{id}admin ideas:editUpdate category
DELETE/v1/ideas/categories/{id}admin ideas:deleteDelete category
DELETE/v1/ideas/tags/{id}admin ideas:deleteDelete 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.

ParameterValuesDescription
statusopen, planned, in_progress, testing, completed, closedFilter by status
category_id / categoryIdUUIDFilter by category
tag_ids / tagIdsUUID list (REST: comma-separated)Filter to ideas carrying the specified tags
author_id / authorIdUUIDFilter by author
searchstringSearch on title and description
sortnewest, most_voted, most_commented, recently_updatedSort order (default newest)
limitintegerPage size (default 20, clamped to 1–100)
offsetintegerRow offset (default 0)

Validation Limits

Identical on both protocols:

FieldLimit
Idea title200 characters
Idea description50 000 bytes
Tags per idea20
Tag name1–50 characters
Comment body10 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.

ChannelGateDescription
ideas:listPublic when system:ideas_hub_publicIndex channel. Carries idea:created.
ideas:{id}Public when system:ideas_hub_publicPer-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_hub and system: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:

PermissionDescription
ideas:readRead ideas
ideas:createSubmit ideas and create tags
ideas:editEdit your own idea
ideas:deleteDelete your own idea
ideas:voteCast and remove votes
ideas:comment_readRead comments
ideas:comment_createPost comments
ideas:comment_editEdit your own comment
ideas:comment_deleteDelete your own comment
ideas:comment_voteCast 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:

PermissionDescription
ideas:moderate_readView the admin ideas management pages and moderation queue
ideas:moderate_statusChange the status of any idea
ideas:moderate_editEdit any idea's content; gates the admin category/tag management pages
ideas:moderate_deleteDelete any idea
ideas:moderate_commentDelete 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

FilePurpose
apps/api/src/graphql/ideas.rsGraphQL queries and mutations
apps/api/src/routes/ideas.rsREST handlers
apps/api/src/db/ideas.rsDB 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.tsNext.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.rsUser-scope and admin-scope permission constants
crates/lo-common/src/sanitize.rsShared ammonia HTML sanitizer (allows mention spans)
crates/lo-websocket/src/gate.rschannel_gate_for / channel_feature_for for ideas:* channels
crates/lo-email/src/templates.rsIdeaMention transactional email template