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
- Tap New Idea in the top-right corner
- Fill in:
| Field | Required | Limit |
|---|---|---|
| Title | Yes | 200 characters |
| Description | No | 2,000 characters |
| Category | Yes | feature / improvement / bug / other |
- If tags are enabled (
feedback_tags_enabled, currently 0% rollout — see Admin: Tag Management), optionally pick up toMAX_TAGS_PER_POST(3) tags from a picker (rails_api/app/models/feedback_post.rb:10,NewFeedbackModal.vue:25) - Tap Submit — post appears in the board with
openstatus 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
| Control | Options |
|---|---|
| Search box | Free text, debounced 300ms — matches title/description case-insensitively (ionic_frontend/src/views/Feedback.vue:29-43, backend FeedbackPost.search scope) |
| Post filter tabs | All / My Posts / My Votes (ionic_frontend/src/views/Feedback.vue:422-426) |
| Category filter chips | All / Features / Improvements / Bugs / Other |
| Sort dropdown | Most 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
→ declinedAny 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.
| Column | Type | Notes |
|---|---|---|
public_id | string | External identifier, unique |
title | string | Max 200 chars, required |
description | text | Max 2,000 chars, optional |
category | integer (enum) | feature: 0, improvement: 1, bug: 2, other: 3 |
status | integer (enum) | open: 0, planned: 1, in_progress: 2, completed: 3, declined: 4 |
vote_count | integer | Counter cache from FeedbackVote |
comment_count | integer | Counter cache from FeedbackComment |
user_id | integer (FK) | Post author |
shipped_at | datetime, nullable | Set 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) |
demo | boolean | Demo-data marker (DemoTaggable) |
deleted_at | datetime | Soft 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.
| Column | Type | Notes |
|---|---|---|
public_id | string | External identifier, unique |
name | string | Max 40 chars, required, case-insensitive unique |
slug | string | URL-safe, auto-generated from name on create, unique |
description | text | Optional |
position | integer | Display order |
archived_at | datetime, nullable | Archived tags are excluded from the pickable scope but stay attached to posts that already carry them |
posts_count | integer | Counter 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).
| Column | Type | Notes |
|---|---|---|
feedback_post_id | bigint (FK) | — |
feedback_tag_id | bigint (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.
| Column | Type | Notes |
|---|---|---|
user_id | integer (FK) | Voter |
feedback_post_id | integer (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.
| Column | Type | Notes |
|---|---|---|
public_id | string | External identifier, unique |
body | text | Max 1,000 chars, required |
user_id | integer (FK) | Comment author |
feedback_post_id | integer (FK) | Parent post |
deleted_at | datetime | Soft 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
# 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):
| Argument | Type | Description |
|---|---|---|
category | String | Filter: feature, improvement, bug, other |
status | String | Filter: open, planned, in_progress, completed, declined |
sort | String | votes (default) or newest |
search | String | Case-insensitive ILIKE match on title/description (FeedbackPost.search scope) |
filter | String | my_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
# 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
| Component | Location | Purpose |
|---|---|---|
FeedbackPage | src/views/Feedback.vue | Main page — board/roadmap toggle, search, filters, sort |
FeedbackCard | src/components/feedback/FeedbackCard.vue | Card with inline vote toggle; restructured layout + tag row when feedback_tags_enabled is on |
FeedbackRoadmap | src/components/feedback/FeedbackRoadmap.vue | Kanban columns by status |
NewFeedbackModal | src/components/feedback/NewFeedbackModal.vue | Create post form; optional tag picker when feedback_tags_enabled is on |
FeedbackDetailModal | src/components/feedback/FeedbackDetailModal.vue | Full post view with comments, status editor, and (flag-gated, admin-only) tag editor |
TagFilterSheet | src/components/feedback/TagFilterSheet.vue | Any-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:
| Field | Rule |
|---|---|
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. |
avatarUrl | The 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_atand the model'sshippedscope exist as groundwork, but nothing renders a feed from them yet. - Author notifications on status change — voters are notified today (
Feedback::NotifyVotersJob, triggered off thefeedback.post_status_changeddomain 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) andfeedbackPosts(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
votedByCurrentUseris computed per-request againstcontext[:current_user]— unauthenticated requests always returnfalse. 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_paranoiddefault scope) - Posting your first feedback post triggers the
voice_heardbadge; a user's 10th triggerscommunity_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