Skip to main content

Architecture: Admin Feedback Visibility & NPS Score Dashboard

Decision​

AlternativeProsConsDecision
MongoDB-backed feedback + campaign-based NPS (chosen)Leverages existing MongoDB, no new infrastructure, admin-controlled campaignsRequires NPS campaign managementSelected
Langfuse-only analyticsAlready integratedNo admin dashboard, limited filtering, no NPSRejected
Third-party survey tool (Typeform, Delighted)Purpose-built NPSExternal dependency, data fragmentation, costRejected
Always-on NPS (no campaigns)Simpler implementationSurvey fatigue, no time-bounded measurementRejected

Solution Architecture​

Feedback Flow​

Feedback is persisted to MongoDB alongside the existing Langfuse path:

User clicks thumbs up/down
│
├── POST /api/feedback ──▶ Langfuse (existing, unchanged)
│
└── PUT /api/chat/messages/[id] ──▶ MongoDB messages collection
└── $set: { feedback: { rating, reason, comment, submitted_at, submitted_by } }

Admin reads feedback from the messages collection where feedback.rating exists:

GET /api/admin/feedback?rating=negative&page=1&page_size=20
│
└── db.messages.find({ "feedback.rating": { $exists: true } })
├── $lookup: conversations ──▶ conversation title, owner
├── $sort: feedback.submitted_at descending
└── $skip / $limit: pagination

NPS Campaign System​

NPS surveys are controlled by admin-created campaigns:

Admin creates campaign
POST /api/admin/nps/campaigns
└── { name, starts_at, ends_at }
└── Overlap check: no two campaigns can be active simultaneously

User checks for active campaign
GET /api/nps/active
└── db.nps_campaigns.findOne({
starts_at: { $lte: now },
ends_at: { $gte: now },
stopped_at: { $exists: false }
})
└── Returns: { active: boolean, campaign: { name, id } }

User submits NPS response
POST /api/nps
└── { score: 0-10, comment?, campaign_id }
└── Stored in nps_responses collection

Admin stops campaign early
PATCH /api/admin/nps/campaigns
└── $set: { stopped_by, stopped_at }
└── Inline confirmation in UI (no browser popup)

NPS Score Calculation​

GET /api/admin/nps?campaign_id=X
│
├── Score = (% Promoters) - (% Detractors)
│ Promoters: score 9-10
│ Passives: score 7-8
│ Detractors: score 0-6
│
├── 30-day trend: daily NPS scores for sparkline chart
│
└── Per-campaign filtering via campaign_id query param

Feature Gating​

Two independent feature flags control visibility:

FeatureEnv VarDefaultControls
FeedbackFEEDBACK_ENABLEDtrue (on)Feedback tab, feedback API
NPSNPS_ENABLEDfalse (off)NPS tab, NPS survey, campaign API

The admin page dynamically adjusts tab grid columns based on which features are enabled.

Read-Only Admin Audit Access​

When admins click feedback chat links, they access conversations in read-only mode:

requireConversationAccess() returns access_level: "admin_audit"
│
└── ChatPanel renders with readOnly=true
├── Audit banner: "You are viewing this conversation in read-only mode"
├── Message input disabled
└── "Back to Feedback" navigates to /admin?tab=feedback

MongoDB Collections​

CollectionPurposeKey Fields
messages (existing)Feedback data on message documentsfeedback.rating, feedback.reason, feedback.submitted_by
nps_responses (new)NPS survey responsesuser_email, score, comment, campaign_id, created_at
nps_campaigns (new)Admin-created NPS campaignsname, starts_at, ends_at, created_by, stopped_at

Components Changed​

FileDescription
ui/src/lib/config.tsAdded feedbackEnabled (default true) and npsEnabled (default false) config flags
ui/src/app/api/admin/feedback/route.tsGET endpoint for paginated feedback with rating filter and conversation lookup
ui/src/app/api/admin/nps/route.tsGET endpoint for NPS analytics with score calculation and 30-day trend
ui/src/app/api/admin/nps/campaigns/route.tsPOST/GET/PATCH for campaign CRUD with overlap prevention
ui/src/app/api/nps/route.tsPOST for submitting NPS responses
ui/src/app/api/nps/active/route.tsGET to check for active campaign
ui/src/app/(app)/admin/page.tsxConditional Feedback and NPS tabs with deep-linkable ?tab= support
ui/src/lib/api-middleware.tsadmin_audit and shared_readonly access levels; write blocking for both
ui/src/components/chat/ChatPanel.tsxreadOnly and readOnlyReason props with contextual banners