Feedback API
The feedback API collects two kinds of user feedback plus client error reports.
- App feedback at
/admin/v1/app-feedback— bug reports, feature requests, other comments, and Contact Support requests submitted via the in-app feedback widget. A Contact Support request additionally sends an email to Myra support (see Contact Support below). - Session feedback at
/admin/v1/feedback— per-conversation ratings submitted at the end of a chat session. - Client errors at
/admin/v1/client-errors— uncaught exceptions and failed fetch reports captured by the SPA.
All endpoints require an authenticated admin session, with one exception: POST /admin/v1/client-errors is public and unauthenticated, so the SPA can report crashes before an admin session exists.
App feedback — listing entries
GET /admin/v1/app-feedback
Required permission: FEEDBACK_TRIAGE (platform admin). A tenant admin cannot read the inbox — submitting feedback and triaging it are different privileges.
Optional query parameters:
type— one ofbug,feature,other,support.status— narrow the inbox to ONE triage lifecycle status. The value is validated against the closed status enum (received,triaged,responded,escalated,resolved,auto_resolved,needs_human); an unknown value is ignored (the full inbox is returned — fail closed, never bound into SQL). The CS-automation surface passesstatus=needs_humanto show only the human queue — items the automation could not safely auto-resolve or auto-ticket. Praise that was auto-resolved and actionable bug/feature that was auto-ticketed drop out, so the manual pile shrinks to only what a human must see.limit— maximum entries to return. Default50; a value above200is silently capped at200.offset— number of entries to skip, for paging. Default0.
Returns an array of entries. Each entry contains:
| Field | Type | Description |
|---|---|---|
id |
string | Entry identifier. |
user_id |
string | null | Submitting user. |
user_email |
string | null | Email of the submitting user. |
type |
bug | feature | other | support |
Category. support is a Contact Support request. |
summary |
string | Short summary. |
description |
string | null | Long description. |
url |
string | null | URL of the page from which the feedback was submitted. |
client_context |
object | null | Client-side context captured with the submission (as documented for Reports); null when none. |
created_at |
unix seconds | Submission timestamp. |
processed |
0 | 1 | Processed flag. |
status |
string | null | Triage lifecycle status (see Automated triage). |
category |
string | null | AI category (bug, feature_request, question, praise, complaint, other). |
severity |
string | null | AI severity (low, medium, high, critical). |
confidence |
integer | null | AI confidence percent (0–100). |
linked_ticket |
string | null | The YouTrack issue id (e.g. AGF-1234) this item was auto-created/linked to, or null. |
responded_at |
unix seconds | null | When a user-facing response was sent, or null. |
image_content_type |
string | null | Legacy single-inline image type (image/png/image/jpeg) for entries submitted before multi-image support; null otherwise. |
image_count |
integer | How many evidence images this entry carries. The image bytes are not included in the list — fetch them by id (see evidence images below). |
App feedback — submitting an entry
POST /admin/v1/app-feedback
Required role: authenticated. Submitting type: "support" additionally requires the SUPPORT_CONTACT permission (see Contact Support).
Submits a new entry. Both summary and description are required.
Request body fields:
| Field | Type | Required | Result |
|---|---|---|---|
summary |
string | Yes | Short summary; scrubbed to valid UTF-8 and truncated to 255 bytes. Absent, explicit null, or a whitespace-only value → 400 ("summary required"), nothing persisted. |
description |
string | Yes | Long description; scrubbed to valid UTF-8 and truncated to 4000 bytes. Absent, explicit null, or a whitespace-only value → 400 with a stable {"error": "…", "code": "description_required"}, nothing persisted. Absent and whitespace-only are answered identically — a required field's rejection does not degrade to a permissive path. (Server-side "whitespace-only" is ASCII whitespace — the same %s blank test summary uses; the widget additionally trims Unicode whitespace client-side, so a Unicode-space-only value never reaches the server from the app. Consistent with summary, a non-string value — number/object — is coerced via tostring rather than rejected; the presence/blank rule is what is enforced here.) |
type |
string | No | See the table below. |
url |
string | null | No | Originating page URL; truncated to 1024 bytes. |
client_context |
object | No | Diagnostic envelope (validated separately; see feedback validation). |
image / images |
see Evidence image below | No | Optional screenshot(s). |
The description guard is applied alongside the summary guard — before the SUPPORT_CONTACT authorization check — so a blank-description submission of any type (including support) is answered 400 description_required and never consults, nor leaks, the permission. Branch on the top-level code, never on the error prose — see Error codes.
type is optional. Absent and malformed are treated differently, deliberately:
type |
Result |
|---|---|
| omitted | Defaults to other. |
bug / feature / other |
Used as given. |
support |
A Contact Support request — see below. Requires SUPPORT_CONTACT. |
any other value — an unrecognised string, a non-string (number, boolean, array, object), or an explicit null |
Rejected 400, nothing is persisted. The body carries a stable code: {"error": "…", "code": "invalid_type"}. |
A value that arrives wrong is not silently reinterpreted as other; only an absent field is defaulted. Branch on the top-level code, never on the error prose — see Error codes.
Contact Support
type: "support" is the in-app route to Myra support. It is gated by the SUPPORT_CONTACT permission, which the admin and tenant_admin system roles hold by default; because it is a tenant-scoped (non-platform) permission, a tenant admin may also delegate it to a custom role. A caller without it receives 403 and nothing is written — the client hiding the option is only a convenience, the server is the boundary.
On success the entry is stored exactly like any other feedback (and mirrored into the triage queue), and an email is sent to the configured support mailbox containing the tenant, the submitting user's name and email, the summary, the description, and a link back to the stored entry. The stored row is the durable record: if the email cannot be delivered the request is not lost — it is still in the feedback inbox, and the failure is logged and raised to ops.
The recipient is the support_email global setting; it is never taken from the request.
Rate limit. Contact Support requests are limited to 3 per hour per user and 10 per hour per tenant. Over either limit the response is 429 with {"error": "…", "code": "support_rate_limited"} and no entry is created. Other feedback types are unaffected — a rate-limited user can still file a bug report. The limits are applied after the permission check, so an unauthorised caller can never consume another tenant's budget.
Optional evidence images
The body may carry optional screenshots so a reporter can attach visual evidence. Two shapes are accepted:
| Field | Type | Notes |
|---|---|---|
images |
array | Up to 6 objects { image, image_content_type } (below). Over the cap → 413 image_too_many, and no entry is created. |
image |
string (bare base64) | Legacy single-image form (still accepted). The image bytes, base64-encoded with no data: prefix. Equivalent to images: [{ image, image_content_type }]. Ignored when images is present. |
image_content_type |
string | The client-declared MIME. A hint only — used to detect a spoof, never trusted as the authoritative type. |
Each element of images has the same { image, image_content_type } fields as the legacy pair. Every image is validated server-side, fail-closed at the trust boundary (the client-declared type and any filename are hostile and never trusted):
- The authoritative type is derived from the magic bytes. PNG and JPEG are stored directly. An Apple/other image format (HEIC/HEIF/AVIF, DNG, WebP, GIF, TIFF, BMP) is first converted server-side to a downscaled JPEG (≤2048 px, EXIF-stripped) and that JPEG is then validated + stored — so an iPhone photo is accepted, not rejected. A non-image payload, a spoofed declared type that decodes to nothing, an executable, or an SVG is still rejected.
- Size cap: 10 MiB (raw, pre-base64). An over-long encoded body is rejected before it is decoded.
- Dimension cap: the decoded image must be a parseable PNG/JPEG of at most 25 megapixels (a small file declaring enormous dimensions — a decompression/canvas bomb — is rejected).
A rejected image never creates an entry. The validation rejections below answer a 4xx whose error is the bare image_<reason> token; the two malware-scan rejections (last rows) instead carry operator prose in error plus a stable top-level code (malware_detected / scan_unavailable) that a client keys its message off, and the 503 carries Retry-After:
| Reason | Status | Meaning |
|---|---|---|
image_bad_encoding |
400 |
image is not a decodable base64 string (includes a non-string value — no 500). |
image_empty |
400 |
The decoded payload is empty. |
image_not_an_image |
400 |
The magic bytes are not PNG/JPEG (spoof, wrong type, or a non-image payload). |
image_bad_dimensions |
400 |
The bytes claim to be PNG/JPEG but the dimensions cannot be parsed. |
image_too_large |
413 |
Over the 10 MiB size cap (or an over-long encoded body). |
image_too_many_pixels |
413 |
Over the 25-megapixel dimension cap. |
image_too_many |
413 |
More than 6 images in images. |
image_not_an_image |
400 |
An images element is not an object ({ image, image_content_type }). |
code: "malware_detected" |
422 |
The malware scanner flagged an image (see Malware scanning below). error is operator prose ("This file was blocked by malware scanning."); the signature is logged operator-side only, never returned. |
code: "scan_unavailable" |
503 + Retry-After |
Malware scanning applies but could not be completed — the scanner was unreachable/errored, or whether scanning applies could not be determined. The submission is refused rather than stored unscanned. Retryable. |
A rejection of any image rejects the whole submission (no partial entry). On success each stored image is the base64 of the exact validated bytes (never the raw client string), kept in attach order and removed when the entry is deleted.
Malware scanning (fail-closed, opt-in per gateway). Evidence images are persisted and served back raw to platform triage, so they are malware-scanned like every other persist-and-serve upload. Scanning is enabled per gateway (av_scan.enabled in a gateway config) and off by default; a feedback entry has no gateway, so an image is scanned when the reporter's tenant has malware scanning enabled on any of its gateways — otherwise this step is skipped. The order is fixed: every image is validated first (a malformed image is answered 4xx without consulting the scanner), then enablement is resolved once, then each image's exact validated bytes — the very bytes that get stored; for a converted HEIC/other-format upload that is the produced JPEG — are streamed to the self-hosted ClamAV daemon, and only then are the Contact-Support rate windows charged and the entry written. An infected verdict rejects the whole submission with 422, code: "malware_detected"; a scanner that is unreachable, times out or errors — or an enablement that cannot be determined (the tenant's gateway list is unreadable or a gateway config will not decode) — rejects it with 503, code: "scan_unavailable" and a short Retry-After, never storing the images unscanned. Residuals, by design: a submission from a tenant that has not opted in, or from an account with no tenant at all (a platform admin's normal shape, or a tenantless user — owning no gateway, none can have opted in), is stored unscanned; the raw bytes handed to the server-side image converter are not scanned (only its output is — decoder hardening is the converter sandbox's job); and the image/normalize preview helper below persists nothing and is not scanned (what it returns is scanned when submitted). The ClamAV endpoint is deployment infrastructure configured by your operator, consulted only when a gateway has opted in. The SPA maps both codes to translated copy and records the rejection in the attach beacon (malware / scan_unavailable; note the scan_unavailable beacon kind is shared with the image-normalizer-at-capacity reject image_server_busy, so a beacon alone does not tell the two transient states apart — the server log does).
App feedback — normalize an image (preview helper)
POST /admin/v1/app-feedback/image/normalize
Auth: any authenticated user (the same as submitting — a reporter normalizing their own attachment); no FEEDBACK_TRIAGE permission required. Pure transform — stores nothing.
The feedback dialog calls this when the browser cannot decode a picked file (an iPhone HEIC/HEIF), so it can show a preview and submit a storable format. Browsers can't render HEIC; the server reuses the image decoder (image_strip, libheif/rawpy) to convert it.
| Field | Type | Notes |
|---|---|---|
image |
string (bare base64) | The raw picked bytes, base64-encoded, no data: prefix. Untrusted. |
image_content_type |
string | null | Client-declared MIME. A hint only; the type is derived from magic bytes. |
- A PNG/JPEG input is validated and returned unchanged (no conversion).
- Any other decodable image (HEIC/HEIF/AVIF/DNG/WebP/GIF/TIFF/BMP) is converted to a downscaled JPEG (≤2048 px longest side, EXIF-stripped), which is then re-validated (magic + 10 MiB + 25 MP bounds) before it is returned — the endpoint never returns an unbounded image.
Success 200: { "image": "<jpeg base64>", "content_type": "image/jpeg" } (or the original content_type for a pass-through PNG/JPEG).
| Error | Status | When |
|---|---|---|
image_bad_encoding |
400 |
image is missing / not a decodable base64 string (a non-string value is rejected here, never a 500). |
image_unsupported |
422 |
The bytes could not be decoded (unknown/corrupt/truncated format, or a decompression bomb). |
image_too_large |
413 |
Over the 10 MiB size cap (or an over-long encoded body). |
image_too_many_pixels |
413 |
The produced image is over the 25-megapixel cap. |
image_server_busy |
503 |
The image-conversion workers are at capacity — a transient condition, retry shortly (the image is fine). |
App feedback — evidence image
GET /admin/v1/app-feedback/<ID>/image
Required permission: FEEDBACK_TRIAGE (a platform-admin permission).
Returns the entry's evidence image as base64 in JSON (the SPA renders it as a data: URL — the admin API is a separate origin whose img-src CSP forbids a cross-host <img src>):
| Field | Type | Description |
|---|---|---|
id |
string | Entry id. |
mime |
string | image/png or image/jpeg (server-derived). |
size_bytes |
integer | Raw (pre-base64) byte count. |
data |
string | The image bytes, base64-encoded. |
An entry with no image, or an unknown <ID>, returns 404. The response carries Cache-Control: private, no-store (feedback screenshots may contain PII, so no shared cache retains them). This legacy endpoint serves the single inline image of pre-multi-image entries; use /images (below) for the full set.
App feedback — evidence images (all)
GET /admin/v1/app-feedback/<ID>/images
Required permission: FEEDBACK_TRIAGE.
Returns all of the entry's evidence images (0..N), in attach order, as base64 in JSON:
| Field | Type | Description |
|---|---|---|
id |
string | Entry id. |
images |
array | Objects { id, mime, size_bytes, data } — data is the base64 image, mime the server-derived image/png/image/jpeg. An empty array when the entry has no image (never a 404 for an existing entry). |
The same Cache-Control: private, no-store applies. Entries submitted before multi-image support return their single legacy inline image as a one-element array.
App feedback — updating an entry (processed flag or editable fields)
PATCH /admin/v1/app-feedback/<ID>
Required permission: FEEDBACK_TRIAGE (platform admin).
A single PATCH does either a processed-toggle or a content edit — the two modes are mutually exclusive. A body that mixes them (a processed key together with any editable field) is rejected with 400.
Processed toggle — a body carrying processed:
| Field | Type |
|---|---|
processed |
boolean |
Content edit — a body carrying any editable field. This is a partial update: an omitted field is left untouched; a field present with an empty/null value is cleared to SQL NULL.
| Field | Type | Notes |
|---|---|---|
summary |
string | Required and non-blank on an edit; truncated to 255. Blank/missing → 400. |
type |
string | Coerced to one of bug, feature, other, support — any other value becomes other. This differs from POST, deliberately: PATCH is the platform-admin triage editor, where coercion normalises a legacy or hand-written row instead of locking an admin out of editing it; POST rejects a malformed type instead. Re-typing an entry to support here does not send an email — only the original submission does. |
description |
string | null | Optional; truncated to 4000. Empty/null clears it. |
url |
string | null | Optional; truncated to 1024. Empty/null clears it. |
An unknown <ID> returns 404. On a content edit, the mirrored triage-queue row (model_error where source = 'app_feedback') is kept coherent — its title follows summary and its detail follows description.
App feedback — deleting an entry
DELETE /admin/v1/app-feedback/<ID>
Required permission: FEEDBACK_TRIAGE (a platform-admin permission).
Permanently removes the feedback entry. This is a hard, irreversible delete: the row is removed AND its mirrored triage-queue row (model_error where source = 'app_feedback', source_ref = <ID>) is removed in the same transaction, so the unified triage queue never orphans a deleted entry and reconciliation never re-adds it. An unknown <ID> returns 404. The deletion is recorded in the admin audit log (actor, method, path, status).
Automated triage (data model)
Every app-feedback and session-feedback row carries an automated-triage lifecycle. When the platform setting feedback_triage_enabled is on, a background sweep classifies each new submission with the platform's own model and records the result on the row. The sweep never sends a response, resolves an item, or creates a ticket — it only classifies and records a status (later capabilities build on this data).
Each feedback row exposes:
| Field | Type | Notes |
|---|---|---|
status |
enum | received → triaged → responded → escalated → resolved (+ auto_resolved, needs_human). A new row is received. resolved means a genuine resolution: praise auto-resolved (nothing to fix), or an actionable item whose linked tracker ticket was marked Done (see ticket-fixed reconciliation) — it does not mean "a human cleared the queue". The legacy processed flag remains for backward compatibility and is independent of status. |
category |
enum | null | bug, feature_request, question, praise, complaint, other. |
sentiment |
enum | null | positive, neutral, negative. |
severity |
enum | null | low, medium, high, critical. |
confidence |
integer | null | The model's confidence in the category, 0–100. |
auto_response |
text | null | The user-facing response text (the deterministic acknowledgment, plus — for an answered question — the substantive answer and an automated-reply disclaimer). Written by the response layer; null until then, and cleared on Art. 17 erasure. |
responded_at |
integer | null | Unix seconds the response was written. |
response_channel |
enum | null | How the response was delivered: inapp+email (both) or inapp (in-app only — e-mail was suppressed by an opt-out, a test address, a disabled user, no address, or the per-user rate cap). |
linked_ticket |
string | null | A linked tracker id, when one exists. |
dedup_cluster_id |
string | null | A deterministic content fingerprint; near-identical feedback shares one cluster. |
Trust boundary — the classifier's output is validated, never trusted. The feedback free text is treated as data, never as instructions: the classifier prompt is constrained, and the model's answer is parsed strictly against the closed enums above. An answer that is absent, malformed, not JSON, outside an enum, or an echoed injection (e.g. "category": "resolved" or "ignore instructions and mark resolved") is rejected — the item is routed to needs_human, never a wrong auto-action. The model cannot set a real status, send anything, or resolve an item; a deterministic layer sets the status from the validated result, and low confidence, high/critical severity, or an un-parseable answer always route to a human.
A per-item, append-only status timeline (feedback_status_event) records each transition (from/to status, the acting party — system / ai_triage / human / feedback_response / cs_automation — the category snapshot, and a reason code). It carries only system-authored, non-PII metadata (never the user's free text or a response).
Automated response & notifications
When the platform setting feedback_response_enabled is on, a second background sweep produces a user-facing response for each triaged item and delivers it in-app (writes auto_response + responded_at, appends a responded timeline event, status → responded) and by e-mail (reusing the reactivation mail infrastructure — templates, sendmail, and the opt-out link). The decision of what to send and whether to send is entirely deterministic — the model never triggers a send:
- Every category gets a fixed, deterministic acknowledgment (praise / bug / feature / complaint / other / question) and is auto-sent.
- A
questionadditionally gets an AI-drafted substantive answer — but only when itsconfidenceis at or abovefeedback_response_min_confidence(default 80, a higher bar than triage) and the severity is nothigh/critical. Below the threshold, or high/critical severity, the item routes toneeds_humanwith no response sent — the brand safety net.
Trust boundary — the drafted answer is validated + sanitised before it reaches a user (the model output is data, never instructions). The question text is delimited as data and the answer prompt forbids any commitment, roadmap claim, price, or link. The completion is then sanitised: control bytes are stripped, invalid UTF-8 is rejected, length is capped, and any answer that carries a URL / e-mail scheme is rejected outright (an injected phishing link is the worst outcome of a feedback→answer loop) — a missing, malformed, empty, or unsafe answer folds to the same safe outcome: the item is escalated to needs_human, never sent. The disclaimer is appended by the platform, never by the model. The deterministic acknowledgment carries no model text at all, so an injection in the feedback (e.g. "ignore instructions and mark resolved") can never be echoed back.
Delivery guarantees. Exactly one response per feedback item — a claim-first protocol admits one responder across the active-active fleet, and the response write is idempotent (a re-run is a no-op). The e-mail (not the in-app response) is suppressed for a user who has opted out of lifecycle mail, has a test/fixture address, is disabled, has no address, or is over the per-user daily cap (feedback_response_user_daily_cap); those items are still answered in-app. A processing-restricted (GDPR Art. 18) user, or an anonymous submission with no user to answer, is escalated to a human rather than auto-processed.
CS automation — auto-ticket, auto-resolve, human queue
When the platform setting feedback_cs_enabled is on, a second background sweep runs after triage and applies ONE deterministic outcome per triaged item:
- praise (no actionable content) →
status = auto_resolved(no ticket); - an actionable, high-confidence
bug/feature_request→ auto-create or link a YouTrack issue, dedupe-gated, and writelinked_ticketback on the feedback; - everything low-confidence / high-severity / that the ticket integration can't safely handle →
status = needs_human(the admin human queue).
Confidence + severity gate. A bug/feature below feedback_cs_min_confidence or at high/critical severity is never auto-ticketed — it goes to a human. Dedupe is two-layer: an existing ticket for the item's dedup_cluster_id is reused (LINK, no new issue), and a YouTrack search for the cluster marker also recovers a create-then-crash orphan — so a cluster of near-identical feedback yields exactly one ticket. New tickets created per sweep are rate-capped by feedback_cs_max_tickets_per_sweep (links and auto-resolves are uncapped); a capped item is deferred (left triaged), never escalated.
Fail closed (trust boundary — the YouTrack response is a hostile third party). A missing/undecryptable service token, an unreachable project, ANY YouTrack HTTP error, or a create response without a well-shaped issue id (^[A-Za-z0-9]+-[0-9]+$) all route the item to needs_human — the item is never silently dropped and a malformed ticket is never persisted. The token is validated for header-injection safety (no whitespace/control bytes) before it is ever placed in the Authorization header.
Ticket-fixed reconciliation (closing the loop honestly). A ticketed item stays triaged/responded (shown to the reporter as "In review") until its fix actually ships. Before each ticketing pass, the sweep reconciles every linked, not-yet-resolved item against YouTrack: it advances the feedback to resolved only when the linked ticket's Stage is Done (isResolved). A ticket closed as "Won't fix" (or any other resolved-but-not-fixed stage) is isResolved too but is never treated as fixed — so a declined report is never falsely shown as "resolved". The transition is forward-only (a later reopen does not revert it), appends a ticket_done timeline event, and is bounded per sweep (distinct ticket look-ups are capped and de-duplicated, with a short-circuit on a dead tracker). The linked-ticket id is validated against ^[A-Za-z0-9]+-[0-9]+$ before any request; a transport/HTTP error leaves the item unchanged (fail closed — never a false resolve).
YouTrack integration configuration
GET /admin/v1/feedback/youtrack — read the current config. Required permission: SETTINGS_MANAGE (platform admin — this is a platform credential).
Returns { "base_url": string, "project": string, "token_set": boolean }. The service token value is never returned — only whether one is set.
PUT /admin/v1/feedback/youtrack — set the config. Required permission: SETTINGS_MANAGE.
| Field | Type | Required | Description |
|---|---|---|---|
base_url |
string | Yes | The YouTrack base URL. Must be an https:// URL (the token rides this connection) without whitespace or control characters; ≤ 512 bytes; a trailing slash is trimmed. A non-https or malformed value is rejected 400. |
project |
string | Yes | The target project short name (e.g. AGF). 1–64 chars of letters, digits, _ or -; anything else is rejected 400. |
token |
string | No | The YouTrack service token (a permanent token). Optional on update — omit to keep the existing one. When present it must be a non-empty string free of whitespace/control bytes. It is stored encrypted at rest (never plaintext, never an environment variable) and never returned on read. An explicit empty string is rejected. |
The write is audited without the secret (only token_set is recorded, never the token value or its ciphertext). The target project must accept issue creation via the REST API without required custom fields (use a dedicated feedback project, or one whose fields are optional) — otherwise every create fails closed to needs_human.
Response: 200 { "ok": true, "base_url": ..., "project": ..., "token_set": ... }.
CS-automation metrics
GET /admin/v1/feedback/cs-metrics — automation KPIs over a trailing window. Required permission: FEEDBACK_TRIAGE.
Query parameter window_days (default 30, clamped 1–365). Returns:
| Field | Type | Description |
|---|---|---|
handled |
integer | Feedback rows triaged-or-beyond in the window. |
auto_resolved |
integer | Rows auto-resolved. |
needs_human |
integer | Rows escalated to a human. |
tickets_auto_created |
integer | Rows carrying an auto-created/linked YouTrack ticket. |
auto_resolved_pct |
number | auto_resolved / handled, one decimal place. |
escalation_rate_pct |
number | needs_human / handled, one decimal place. |
median_first_response_s |
number | null | Median first-response time (seconds), or null when no response has been sent yet. |
window_days |
integer | The clamped window used. |
Session feedback — listing entries
GET /admin/v1/feedback
Required role: platform admin.
Optional query parameters:
| Parameter | Description |
|---|---|
processed |
Filter by processed flag: true or false. Omit for all. |
project_id |
Restrict to feedback whose conversation belongs to this project. |
limit |
Maximum number of entries to return. Default 100; a value above 500 is silently capped at 500. |
Each entry contains:
| Field | Type | Description |
|---|---|---|
id |
string | Entry identifier. |
conversation_id |
string | Source conversation. |
user_id |
string | Conversation owner. |
email |
string | null | Email of the conversation owner. |
project_id |
string | null | Project the conversation belongs to. |
project_name |
string | null | Resolved project name. |
rating |
1–5 | Rating value (5 = best, 1 = worst). |
comment |
string | null | Optional comment. |
processed |
0 | 1 | Processed flag. |
created_at |
unix seconds | Submission timestamp. |
updated_at |
unix seconds | Last update timestamp. |
Session feedback — updating the processed flag
PATCH /admin/v1/feedback/<ID>
Required permission: FEEDBACK_TRIAGE (a platform-admin permission).
Marks the entry as processed. The request body is ignored; the entry is always set to processed, and the action cannot be undone.
Per-conversation feedback (user-side)
The end-user side of the session feedback flow lives at:
PUT /admin/v1/conversations/<ID>/feedback— submit or update a rating with an optional comment. The caller must own the conversation; a request for a conversation the caller does not own returns404. On success it returns{ "ok": true, "id": "<feedback-id>" }— theidlets the chat feedback dialog show an instant "received, reference #" acknowledgment and a link to My feedback (below). GET /admin/v1/conversations/<ID>/feedback— read the calling user's submitted rating.
These endpoints require authenticated access to the conversation. See Conversations API.
💡 Note: When a user submits or updates a rating via
PUT /admin/v1/conversations/<ID>/feedback, the rating is also mirrored, best-effort, into the unified triage queue (source="chat_feedback", one row per conversation).
My feedback (closed-loop, user-side)
GET /admin/v1/my-feedback
Returns the calling user's own feedback — both app feedback and per-conversation chat ratings — each with its category, lifecycle status, status timeline, and the automated response, so the SPA's My feedback view can show the user what happened with what they submitted.
Strictly user-scoped (isolation is the security invariant). The result is filtered by the session user id only — taken from the authenticated session, never from any request input. A user physically cannot address another user's feedback. A user_id query parameter is accepted and ignored (it is documented here so callers do not rely on it; it can never re-scope the read). Under an audited impersonation session the result is the impersonated (target) user's feedback, consistent with the rest of the impersonation model.
Requires an authenticated session (any role). Unauthenticated → 401.
Response — a JSON array (empty [] when the user has submitted nothing), newest first. Each element:
| Field | Type | Notes |
|---|---|---|
id |
string | The feedback row id. |
source |
string | app_feedback or chat_feedback. |
status |
string | Lifecycle status: received, triaged, responded, escalated, resolved, auto_resolved, or needs_human. |
category |
string | omitted | The AI category (bug, feature_request, question, praise, complaint, other) once triaged; omitted until then. |
created_at |
number | Submission time (unix seconds). |
auto_response |
string | omitted | The automated response text; omitted until one exists. |
responded_at |
number | omitted | When the response was written (unix seconds); omitted until then. |
feedback_type |
string | omitted | App feedback only: bug | feature | other | support. |
title |
string | omitted | App feedback only: the summary. |
body |
string | omitted | The user's own submitted text (app description or chat comment). |
rating |
number | omitted | Chat feedback only: 1–5. |
conversation_id |
string | omitted | Chat feedback only. |
timeline |
array | Append-only status transitions (oldest first): [{ from_status, to_status, actor, category, created_at }]. Empty until the first transition; the SPA renders a synthetic received origin from created_at. |
Not exposed: internal AI triage signals (sentiment, severity, confidence), the dedupe cluster id, any linked ticket, and the response channel are deliberately omitted — the user sees only their category, status, timeline, and response. The user's own free text and the response are returned as data (the SPA renders them as escaped text, never HTML). On a storage fault the endpoint returns 500 with { "error": "internal error" }.
Client errors
POST /admin/v1/client-errors — submit a client-side error report. Public and unauthenticated (the SPA reports crashes before an admin session exists), so the body is fully untrusted and sanitized at this trust boundary.
| Field | Type | Required | Description |
|---|---|---|---|
message |
string | Yes | The error message. Rejected with 400 if missing, null, or blank. Truncated to 2 000 bytes on a UTF-8 codepoint boundary; invalid UTF-8 bytes are scrubbed to U+FFFD. |
id |
string | No | Client-chosen row id. A null, blank, or over-36-byte id is replaced with a server-generated UUID (so a report is never silently dropped by a NULL/oversized primary key). |
stack |
string | No | Stack trace. Truncated to 8 000 bytes on a codepoint boundary; invalid UTF-8 scrubbed; null/blank → SQL NULL. |
url |
string | No | Page URL. Truncated to 500 bytes; scrubbed; null/blank → SQL NULL. |
user_agent |
string | No | User agent. Truncated to 500 bytes; scrubbed; null/blank → SQL NULL. |
ts |
number | No | Accepted and ignored: the row's timestamp is always the server's receive time (unix milliseconds). A client clock cannot date a report into the future or the past, so the 30-day retention sweep is a true age. |
conversation_id |
string | No | Associated conversation (mirrored into the triage queue). Dropped to null if null/blank/over 36 bytes. |
client_context |
object | No | Browser diagnostic snapshot (app/OS version, screen size, viewport, locale, timezone, colour scheme, current route, referrer, …) — the same schema and validator as the application-feedback client_context above. Validated at this trust boundary: unknown keys are clamped, over-size (>16 KiB) or wrong-type context is rejected and stored as NULL, and the report still returns 201. Server-only keys a client tries to inject (user_id, client_ip, tenant_id, …) are stripped. current_route carries the page's query string, so its query is scrubbed to a non-reversible marker before storage (operator-visible logs never leak ?text= / ?project_id= values), exactly as url is. |
Diagnostic context captured server-side (never client-sent). In addition to the body above, the server records:
- Account — if a valid admin session cookie rides along, the report is linked to that user (id + tenant + email). This is soft-resolved: a missing, expired, or malformed session cookie degrades the report to anonymous (
user_id/tenant_idNULL) — never a401/5xx. The user id is taken from the session, never from the request body. - IP — the client IP is derived server-side from the trusted Myra CDN edge, not from any client-sent field.
These give operators the account, tenant, IP, app/OS version, and screen size needed to diagnose an otherwise unattributable browser crash; they surface in the triage queue and under model-errors.sh context <id>. The account id and IP are personal data and are erased on user deletion / tenant purge and included in a user's data export. On erasure/purge the page URL and client_context are nulled too (both can carry client-supplied personal data — a verbatim referrer or arbitrary extra keys); only the bounded error message/stack remain, removed by the 30-day sweep.
A malformed or hostile body never returns a 5xx: null-in-a-truthy-field, invalid UTF-8, and over-length values are all sanitized rather than persisted verbatim; a malformed client_context is dropped to NULL and the report still returns 201.
Message tag conventions. The SPA prefixes its reports so operators can filter the queue: [user-error:<area>] marks a user-visible error the app surfaced in area <area> (e.g. chat, easy-create, login, knowledge-upload, and attach for user-caused attachment rejections: unsupported type, too large, too many, server image rejects, malware/scan states); [attach] marks an app/platform-caused attachment failure (file-read failures, empty reads, a native file chooser that never opened, a null clipboard item, and ingest_busy: the shared upload pool answered 503 and the chat surfaced the busy banner, the signal for sizing the pool) — the operator's bug queue; ui-anomaly: <kind> marks a silent bad on-screen state that threw no exception. Attachment beacons carry only structured facts — ingress source (picker/paste/drop/longpaste/send/feedback), lowercased file extension, MIME, a bucketed byte size, and a per-gesture fold count — never a filename or localized banner text.
Response: 201 { "ok": true }
Retention. Client error reports are kept for 30 days and then removed by an hourly, batched sweep (storage limitation — the report text may contain user-visible content or URLs). The triage queue keeps only the de-duplicated aggregate (fingerprint, title, cumulative count) beyond that; the raw message and stack behind a triage item exist for the retention window only.
GET /admin/v1/client-errors — list submitted client errors (those within the 30-day retention window). Required permission: FEEDBACK_TRIAGE (a platform-admin permission).