Skip to content

Feedback Board — Feature Guide

Overview

The feedback board gives users a direct voice in what gets built. Anyone with an account can post a feature request, improvement suggestion, or bug report. Others vote on it. Admins track progress through a public roadmap. The goal is transparent prioritization — users see what's planned, what's in flight, and what shipped.

Accessible at /feedback inside the app. As of v4.62, the board is also read-only-public: a separate site at feedback.objectuve.com (feedback_site/) lets anyone view posts, votes, and comments without an account, though writing still requires signing in. See Public Feedback Board below — as of this writing that site is built and merged but not yet live for end users (its kill switch is off and its Firebase Hosting site isn't provisioned yet).

Posts can also carry admin-managed tags (FeedbackTag) for finer-grained organization — see Data Model and Admin: Tag Management. Tags are merged but currently dark: the feedback_tags_enabled flag is at 0% rollout, so no real user sees the tag picker, tag chips, or tag filter yet.


User Actions

Create a Post

  1. Tap New Idea in the top-right corner
  2. Fill in:
FieldRequiredLimit
TitleYes200 characters
DescriptionNo2,000 characters
CategoryYesfeature / improvement / bug / other
  1. If tags are enabled (feedback_tags_enabled, currently 0% rollout — see Admin: Tag Management), optionally pick up to MAX_TAGS_PER_POST (3) tags from a picker (rails_api/app/models/feedback_post.rb:10, NewFeedbackModal.vue:25)
  2. Tap Submit — post appears in the board with open status and 0 votes, auto-upvoted by its author (rails_api/app/interactions/feedback/create_feedback_post.rb:28)

Vote / Unvote

  • Tap the upvote button (ChevronUp icon) on any card to cast a vote
  • Tap again to remove it — votes are a toggle, one per user per post
  • Vote count updates optimistically in the UI

Comment

  • Open a post's detail modal (tap anywhere on the card)
  • Type in the comment input and submit
  • Comments are ordered oldest-first, max 1,000 characters each

Filter, Search, and Sort

ControlOptions
Search boxFree text, debounced 300ms — matches title/description case-insensitively (ionic_frontend/src/views/Feedback.vue:29-43, backend FeedbackPost.search scope)
Post filter tabsAll / My Posts / My Votes (ionic_frontend/src/views/Feedback.vue:422-426)
Category filter chipsAll / Features / Improvements / Bugs / Other
Sort dropdownMost Voted (default) / Newest
Tag filter (flag-gated, feedback_tags_enabled)A 4-way filter rail plus a TagFilterSheet for picking any-of tags to narrow the board — merged filter UI on when the flag is on, hidden entirely when it's off (ionic_frontend/src/views/Feedback.vue:336, TagFilterSheet.vue)

Search, the post filter tabs, category chips, and sort are unconditional — they ship for every user regardless of feedback_tags_enabled. Filters apply to the board view only. The roadmap view always shows all posts.

View the Roadmap

Toggle to Roadmap view to see posts organized in kanban columns by status: Open, Planned, In Progress, Completed, and Declined.


Admin Actions

Admins can update the status of any post from the detail modal. The status selector is only visible when current_user.admin? is true.

Status workflow:

open → planned → in_progress → completed
                             → declined

Any transition is valid — admins can move a post to any status directly. A status change publishes a feedback.post_status_changed domain event, which notifies every voter on the post except its own author (Feedback::NotifyVotersJob, rails_api/app/interactions/feedback/subscribers.rb:10).

When feedback_tags_enabled is on, admins additionally see a tag editor in the detail modal (gated on isAdmin && tagsEnabled, FeedbackDetailModal.vue:341) for setting a post's tags directly, alongside the tag management described in Admin: Tag Management below.


Data Model

FeedbackPost

Aggregate root for the Feedback context. Inherits PublicRecord (exposes public_id). Uses acts_as_paranoid for soft deletion.

ColumnTypeNotes
public_idstringExternal identifier, unique
titlestringMax 200 chars, required
descriptiontextMax 2,000 chars, optional
categoryinteger (enum)feature: 0, improvement: 1, bug: 2, other: 3
statusinteger (enum)open: 0, planned: 1, in_progress: 2, completed: 3, declined: 4
vote_countintegerCounter cache from FeedbackVote
comment_countintegerCounter cache from FeedbackComment
user_idinteger (FK)Post author
shipped_atdatetime, nullableSet when a post is marked completed. Exposed via GraphQL but not yet surfaced in any UI — no changelog/shipped feed consumes it (see Phase 2 Planned Features)
demobooleanDemo-data marker (DemoTaggable)
deleted_atdatetimeSoft delete timestamp

Has many feedback_tags through feedback_post_tags (max MAX_TAGS_PER_POST = 3, enforced by a model validation — rails_api/app/models/feedback_post.rb:10,50-53).

FeedbackTag

Admin-managed tag vocabulary, added v4.62. Inherits PublicRecord. Uses acts_as_paranoid.

ColumnTypeNotes
public_idstringExternal identifier, unique
namestringMax 40 chars, required, case-insensitive unique
slugstringURL-safe, auto-generated from name on create, unique
descriptiontextOptional
positionintegerDisplay order
archived_atdatetime, nullableArchived tags are excluded from the pickable scope but stay attached to posts that already carry them
posts_countintegerCounter cache from FeedbackPostTag, via counter_cache: :posts_count

Code: rails_api/app/models/feedback_tag.rb

FeedbackPostTag

Join model between FeedbackPost and FeedbackTag. Plain ApplicationRecord (no public_id, not independently API-exposed — never returned as its own GraphQL type).

ColumnTypeNotes
feedback_post_idbigint (FK)
feedback_tag_idbigint (FK)

Unique index on [feedback_post_id, feedback_tag_id] prevents duplicate tag assignment.

Code: rails_api/app/models/feedback_post_tag.rb

FeedbackVote

Tracks which users voted on which posts. No public_id — not API-exposed directly.

ColumnTypeNotes
user_idinteger (FK)Voter
feedback_post_idinteger (FK)Target post

Unique index on [user_id, feedback_post_id] enforces one vote per user per post. Uses counter_cache: :vote_count to maintain FeedbackPost#vote_count.

FeedbackComment

Inherits PublicRecord. Uses acts_as_paranoid.

ColumnTypeNotes
public_idstringExternal identifier, unique
bodytextMax 1,000 chars, required
user_idinteger (FK)Comment author
feedback_post_idinteger (FK)Parent post
deleted_atdatetimeSoft delete timestamp

Uses counter_cache: :comment_count to maintain FeedbackPost#comment_count.


GraphQL API

Full generated reference (all fields, types, args): GraphQL API Reference § Feedback, § Types — regenerated from rails_api/schema.graphql and already current for v4.62; only the prose below is hand-maintained.

Queries

graphql
# List posts — filterable, searchable, and sortable (authenticated)
query FeedbackPostsQuery($category: String, $status: String, $sort: String, $search: String, $filter: String, $tags: [String!]) {
  feedbackPosts(category: $category, status: $status, sort: $sort, search: $search, filter: $filter, tags: $tags) {
    id
    title
    description
    category
    status
    voteCount
    commentCount
    votedByCurrentUser
    createdAt
    shippedAt
    tags { id name slug }
    user {
      id
      firstName
      lastName
      username
      photo { imageUrl }
    }
  }
}

# Single post with comments
query FeedbackPostQuery($id: ID!) {
  feedbackPost(id: $id) {
    id
    title
    description
    category
    status
    voteCount
    commentCount
    votedByCurrentUser
    createdAt
    tags { id name slug }
    user { id firstName lastName username photo { imageUrl } }
    comments {
      id
      body
      createdAt
      user { id firstName lastName username photo { imageUrl } }
    }
  }
}

# Tags any authenticated user can pick from (non-archived, ordered)
query PickableFeedbackTagsQuery {
  pickableFeedbackTags { id name slug }
}

feedbackPosts arguments (rails_api/app/graphql/resolvers/feedback_queries.rb:5-25):

ArgumentTypeDescription
categoryStringFilter: feature, improvement, bug, other
statusStringFilter: open, planned, in_progress, completed, declined
sortStringvotes (default) or newest
searchStringCase-insensitive ILIKE match on title/description (FeedbackPost.search scope)
filterStringmy_posts (current user's own posts) or my_votes (posts the current user voted on)
tags[String!]Any-of filter by tag slug

feedbackTags(includeArchived: Boolean) (admin only) and pickableFeedbackTags (any authenticated user, non-archived only) round out the tag-reading surface — see GraphQL API Reference § feedbackTags.

Public queries (unauthenticated)

publicFeedbackPosts/publicFeedbackPost power the Public Feedback Board — see that section for the full contract, including which author fields are and are not exposed anonymously.

Mutations

graphql
# Create a post (authenticated) — tagIds optional, up to MAX_TAGS_PER_POST (3)
mutation CreateFeedbackPostMutation($title: String!, $description: String, $category: String!, $tagIds: [ID!]) {
  createFeedbackPost(title: $title, description: $description, category: $category, tagIds: $tagIds) {
    feedbackPost { id title category status voteCount votedByCurrentUser createdAt tags { id name } user { id } }
    errors
  }
}

# Toggle vote on a post (authenticated)
mutation ToggleFeedbackVoteMutation($postId: ID!) {
  toggleFeedbackVote(postId: $postId) {
    feedbackPost { id voteCount votedByCurrentUser }
    voted
    errors
  }
}

# Add a comment (authenticated)
mutation CreateFeedbackCommentMutation($postId: ID!, $body: String!) {
  createFeedbackComment(postId: $postId, body: $body) {
    feedbackComment { id body createdAt user { id firstName lastName username photo { imageUrl } } }
    errors
  }
}

# Update post status (admin only)
mutation UpdateFeedbackPostStatusMutation($postId: ID!, $status: String!) {
  updateFeedbackPostStatus(postId: $postId, status: $status) {
    feedbackPost { id status }
    errors
  }
}

# Set a post's tags (admin only) — replaces the full tag set, up to 3
mutation UpdateFeedbackPostTagsMutation($postId: ID!, $tagIds: [ID!]!) {
  updateFeedbackPostTags(postId: $postId, tagIds: $tagIds) {
    feedbackPost { id tags { id name } }
    errors
  }
}

All mutations require the SessionToken header. updateFeedbackPostStatus returns Admin access required in errors if the current user is not an admin. Tag CRUD (createFeedbackTag, updateFeedbackTag, archiveFeedbackTag) is admin-only too — see Admin: Tag Management.


Frontend Components

ComponentLocationPurpose
FeedbackPagesrc/views/Feedback.vueMain page — board/roadmap toggle, search, filters, sort
FeedbackCardsrc/components/feedback/FeedbackCard.vueCard with inline vote toggle; restructured layout + tag row when feedback_tags_enabled is on
FeedbackRoadmapsrc/components/feedback/FeedbackRoadmap.vueKanban columns by status
NewFeedbackModalsrc/components/feedback/NewFeedbackModal.vueCreate post form; optional tag picker when feedback_tags_enabled is on
FeedbackDetailModalsrc/components/feedback/FeedbackDetailModal.vueFull post view with comments, status editor, and (flag-gated, admin-only) tag editor
TagFilterSheetsrc/components/feedback/TagFilterSheet.vueAny-of tag picker sheet for the board's tag filter (feedback_tags_enabled only)

GraphQL constants live in src/constants/graphql/feedback.js.


Admin: Coach-Analyzed Weekly Summary

The admin Feedback dashboard (admin_dashboard) shows a Coach-analyzed themed summary of the past week's posts — themes with vote totals, plus emerging request patterns. Flag-gated (feedback_ai_summary_enabled), fails closed to no panel when off or when there's nothing to summarize. See AI Coaching Expansion § Feedback weekly summary for the full backend/frontend reference.


Admin: Tag Management

admin_dashboard's Feedback view (admin_dashboard/src/views/FeedbackView.vue, FeedbackTagsTable.vue) lets admins create, rename, and archive tags via the createFeedbackTag/updateFeedbackTag/archiveFeedbackTag mutations. Archiving a tag (FeedbackTag#archive!, sets archived_at) removes it from the pickable/pickableFeedbackTags picker surface without detaching it from posts that already carry it. Same 0% feedback_tags_enabled rollout as the rest of the tag surface applies to the in-app picker/chips/filter — the admin CRUD screen itself is not flag-gated.


Public Feedback Board

v4.62 added a second, unauthenticated way to read the feedback board, plus a standalone site to serve it from. Three pieces:

1. The public GraphQL read path (Phase 6, OBJ-3795) — publicFeedbackPosts(status: String, tags: [String!]) and publicFeedbackPost(id: ID!) (rails_api/app/graphql/types/query_type.rb:169-181, resolver at rails_api/app/graphql/resolvers/public_feedback_queries.rb). No SessionToken required. Rate-limited to 30 requests/minute (per authenticated user, or per request IP when anonymous). Gated by an env-var kill switch, ENV['PUBLIC_FEEDBACK_READ_ENABLED'] — absent or anything other than the literal string 'true' means both fields return null for everyone, admins included. This is currently off in every environment — the capability is merged but dark.

These fields return an allow-listed type family (PublicFeedbackPostType, PublicFeedbackAuthorType, PublicFeedbackCommentType) that is structurally incapable of reaching Types::UserType, by design: no votedByCurrentUser field exists at all (it can't be honestly answered for an anonymous caller), and author identity is deliberately narrow:

FieldRule
displayName"{FirstName} {LastInitial}." (e.g. "Priya S.") normally. Resolves to the literal string "a member" for a private_mode author and for an author with no renderable name — the two cases are byte-identical on purpose, so this field can never be used to detect who has private mode on.
avatarUrlThe user's photo URL normally, null for a private_mode author and for an author with no uploaded photo — same indistinguishability rule.

No email, no username, no integer or public ID beyond the post/comment's own — see rails_api/app/graphql/types/public_feedback_author_type.rb. A dedicated regression spec (rails_api/spec/graphql/schema/public_feedback_schema_regression_spec.rb) proves no path from publicFeedbackPosts/publicFeedbackPost reaches Types::UserType; it exists specifically so a future change that repoints a public author field at Types::UserType fails a test immediately rather than shipping a PII leak.

2. feedback_site/ (Phase 7, OBJ-3796) — a standalone Vue 3 + Vite app (Clerk + Apollo, its own package.json, no shared code with ionic_frontend/) that reads the board anonymously and gates every write (vote, comment, post) behind Clerk sign-in via useWriteGate.ts — a logged-out visitor who tries to vote/comment/post is held in a pending-intent state and shown an auth overlay (AuthInviteOverlay.vue) rather than being blocked outright. Category and free-text search filtering happen client-side over the fetched set, since the public GraphQL fields only accept status/tags server-side (feedback_site/src/views/BoardList.vue:72-74). Its create form (CreateIdeaForm.vue) still calls the authenticated createFeedbackPost mutation — reading is anonymous, writing never is.

3. A crawler-facing HTML path (Phase 7 Task 1, OBJ-3796) — PublicFeedbackController (rails_api/app/controllers/public_feedback_controller.rb, routes at rails_api/config/routes.rb:10-15: GET /feedback, GET /feedback/sitemap.xml, GET /feedback/:public_id(/:slug)) serves real server-rendered HTML (post titles and text in the <body>) to recognized crawler user agents, and redirects everyone else to the feedback_site/ frontend. It reuses the same kill switch and rate limiter as the GraphQL path — never a second, unguarded read path.

Hosting and current status

feedback_site/ deploys to Firebase Hosting target enkidu-feedback, intended for feedback.objectuve.com, on its own independent lane (.github/workflows/feedback.yml) decoupled from the weekly release train — see Deployment § Independent surface lanes. As of this writing, feedback.objectuve.com is not reachable: the GCP Firebase Hosting site resource for enkidu-feedback has never been provisioned (hosting:sites:create hasn't been run, nor has the custom domain been mapped) — repo-side config (.firebaserc, firebase.json) shipped in Phase 7, but that step is a separate, human-gated action. See the gotcha write-up (OBJ-3864) for the full mechanism. Combined with PUBLIC_FEEDBACK_READ_ENABLED being unset, the public board is currently dark for two independent reasons, not one.


Phase 2 Planned Features

Not yet implemented:

  • Changelog — a dedicated changelog feed showing completed posts with release notes. FeedbackPost#shipped_at and the model's shipped scope exist as groundwork, but nothing renders a feed from them yet.
  • Author notifications on status change — voters are notified today (Feedback::NotifyVotersJob, triggered off the feedback.post_status_changed domain event), but the post's own author is explicitly excluded from that notification (rails_api/app/jobs/feedback/notify_voters_job.rb:11) and has no other status-change alert.
  • Vote-threshold gamification milestones — badges already exist for the post author (first post, 10th post — see Known Behaviors), but nothing triggers on a post reaching a vote count.
  • Embeddable roadmap widget — v4.62 shipped a full standalone public board (Public Feedback Board), which is a different, larger thing than an embeddable widget for the marketing site or third-party pages; the widget itself still doesn't exist.

Previously listed here as not-yet-implemented, now shipped:

  • Search — the board's search box (Feedback.vue) and feedbackPosts(search:) do case-insensitive full-text matching across title/description, unflagged. See Filter, Search, and Sort.
  • Tags — admin-managed tags, applied at post creation and filterable on the board. Merged but dark behind feedback_tags_enabled (0% rollout). See Data Model and Admin: Tag Management.
  • Public board — a read-only, unauthenticated view of the board now exists at a separate site, not as an embed. Merged but dark (see Public Feedback Board).

Known Behaviors

  • votedByCurrentUser is computed per-request against context[:current_user] — unauthenticated requests always return false. There is no equivalent field on the public read path at all (see Public Feedback Board).
  • Vote count and comment count are counter caches, updated synchronously on create/destroy — no background job required
  • The roadmap view fetches all posts (no category filter, sort by votes) via a separate Apollo query instance to avoid cache conflicts with the filtered board query
  • Soft-deleted posts and comments are excluded from all queries by default (acts_as_paranoid default scope)
  • Posting your first feedback post triggers the voice_heard badge; a user's 10th triggers community_voice (Feedback::CreateFeedbackPost#check_feedback_badges, rails_api/app/interactions/feedback/create_feedback_post.rb:75-84) — the author is also auto-upvoted on their own post at creation.
  • Tags: at most MAX_TAGS_PER_POST (3) per post, enforced server-side regardless of whether the picker UI is flag-visible; archiving a tag hides it from future picking without detaching it from posts that already have it.

Last updated: 2026-09-15

Loading…