diff --git a/apps/mesh/e2e/tests/feedback.spec.ts b/apps/mesh/e2e/tests/feedback.spec.ts new file mode 100644 index 0000000000..fa45a4d325 --- /dev/null +++ b/apps/mesh/e2e/tests/feedback.spec.ts @@ -0,0 +1,149 @@ +/** + * E2E: POST /api/:org/feedback + * + * Auth, org membership, validation, and payload limits for general and + * chat-negative feedback bodies. + */ + +import { signUpViaApi } from "../fixtures/auth-api"; +import { expect, newApiContext, test } from "../fixtures/test"; + +const FEEDBACK_URL = (orgSlug: string) => `/api/${orgSlug}/feedback`; + +test.describe("POST /api/:org/feedback", () => { + test("returns 200 for authenticated org member (general)", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + data: { message: "E2E general feedback" }, + }); + expect(res.status()).toBe(200); + const body = (await res.json()) as { ok?: boolean }; + expect(body.ok).toBe(true); + + await ctx.dispose(); + }); + + test("returns 200 for chat_negative with reasons", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + data: { + kind: "chat_negative", + messageId: "msg-e2e-1", + threadId: "thread-e2e-1", + reasons: ["Slow or buggy"], + details: "optional detail", + }, + }); + expect(res.status()).toBe(200); + + await ctx.dispose(); + }); + + test("returns 400 when message is empty", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + data: { message: " " }, + }); + expect(res.status()).toBe(400); + + await ctx.dispose(); + }); + + test("returns 400 when chat_negative has no reasons or details", async ({ + playwright, + }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + data: { kind: "chat_negative", messageId: "msg-1" }, + }); + expect(res.status()).toBe(400); + + await ctx.dispose(); + }); + + test("returns 400 for invalid JSON body", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + headers: { "Content-Type": "application/json" }, + data: "not-json", + }); + expect(res.status()).toBe(400); + + await ctx.dispose(); + }); + + test("returns 401 when unauthenticated", async ({ playwright }) => { + const authed = await newApiContext(playwright); + const user = await signUpViaApi(authed); + + const anon = await newApiContext(playwright); + const res = await anon.post(FEEDBACK_URL(user.orgSlug), { + data: { message: "anonymous attempt" }, + }); + expect(res.status()).toBe(401); + + await authed.dispose(); + await anon.dispose(); + }); + + test("returns 403 for a principal who is not a member", async ({ + playwright, + }) => { + const userACtx = await newApiContext(playwright); + const userA = await signUpViaApi(userACtx); + + const userBCtx = await newApiContext(playwright); + await signUpViaApi(userBCtx); + + const res = await userBCtx.post(FEEDBACK_URL(userA.orgSlug), { + data: { message: "cross-org feedback" }, + }); + expect(res.status()).toBe(403); + + await userACtx.dispose(); + await userBCtx.dispose(); + }); + + test("returns 413 when body exceeds limit", async ({ playwright }) => { + const ctx = await newApiContext(playwright); + const user = await signUpViaApi(ctx); + + const huge = "x".repeat(70_000); + const res = await ctx.post(FEEDBACK_URL(user.orgSlug), { + data: { message: huge }, + }); + expect(res.status()).toBe(413); + + await ctx.dispose(); + }); +}); + +test.describe("feedback UI", () => { + test("account menu submits general feedback", async ({ authedPage }) => { + const { page, orgSlug } = authedPage; + await page.goto(`/${orgSlug}`); + + await page.locator('[data-sidebar="menu-button"]').last().click(); + await page.getByRole("button", { name: "Feedback" }).click(); + await expect(page.getByRole("dialog", { name: "Feedback" })).toBeVisible(); + + await page + .getByPlaceholder(/Tell us about your experience/) + .fill("E2E UI feedback from account menu"); + await page.getByRole("button", { name: "Send feedback" }).click(); + + await expect(page.getByText("Feedback sent — thank you!")).toBeVisible(); + }); +}); diff --git a/apps/mesh/src/api/routes/feedback.test.ts b/apps/mesh/src/api/routes/feedback.test.ts new file mode 100644 index 0000000000..fbbca50829 --- /dev/null +++ b/apps/mesh/src/api/routes/feedback.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + FEEDBACK_MAX_TEXT_LENGTH, + parseFeedbackBody, + truncateForLog, +} from "./feedback"; + +describe("parseFeedbackBody", () => { + test("general requires non-empty message", () => { + expect(parseFeedbackBody({ message: " hello " })).toEqual({ + ok: true, + entry: { kind: "general", message: "hello" }, + }); + expect(parseFeedbackBody({ message: "" }).ok).toBe(false); + expect(parseFeedbackBody({}).ok).toBe(false); + }); + + test("chat_negative requires messageId and reasons or details", () => { + expect( + parseFeedbackBody({ + kind: "chat_negative", + messageId: "msg-1", + reasons: ["Slow or buggy"], + details: " extra ", + }), + ).toEqual({ + ok: true, + entry: { + kind: "chat_negative", + message: "extra", + messageId: "msg-1", + threadId: null, + reasons: ["Slow or buggy"], + }, + }); + expect( + parseFeedbackBody({ + kind: "chat_negative", + messageId: "msg-1", + details: "only details", + }).ok, + ).toBe(true); + expect( + parseFeedbackBody({ kind: "chat_negative", messageId: "msg-1" }).ok, + ).toBe(false); + }); + + test("rejects message over max length", () => { + const long = "a".repeat(FEEDBACK_MAX_TEXT_LENGTH + 1); + expect(parseFeedbackBody({ message: long }).ok).toBe(false); + }); +}); + +describe("truncateForLog", () => { + test("truncates long text", () => { + const { preview, truncated } = truncateForLog("x".repeat(600), 10); + expect(truncated).toBe(true); + expect(preview).toBe("xxxxxxxxxx…"); + }); +}); diff --git a/apps/mesh/src/api/routes/feedback.ts b/apps/mesh/src/api/routes/feedback.ts new file mode 100644 index 0000000000..372a0cbd5b --- /dev/null +++ b/apps/mesh/src/api/routes/feedback.ts @@ -0,0 +1,192 @@ +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import type { Env } from "../hono-env"; + +/** Max JSON body size for feedback POSTs. */ +const FEEDBACK_MAX_BODY_SIZE = 65_536; + +/** Max length for free-text fields after trim. */ +export const FEEDBACK_MAX_TEXT_LENGTH = 16_384; + +/** Default sink log preview — avoids dumping multi-KB secrets into stdout. */ +const FEEDBACK_LOG_PREVIEW_LENGTH = 500; + +export type FeedbackKind = "general" | "chat_negative"; + +/** + * A single piece of user-submitted feedback, handed to the configured + * {@link FeedbackSink}. Free-text fields may contain anything — treat as + * untrusted, user-owned content. + */ +export interface FeedbackEntry { + kind: FeedbackKind; + /** General: user message. Chat negative: optional details only. */ + message: string; + orgId: string | undefined; + userId: string | undefined; + messageId?: string; + threadId?: string | null; + reasons?: string[]; +} + +/** + * Where feedback messages go. Studio does NOT decide this for you — provide + * your own sink when mounting the routes (Linear, Slack, DB, webhook, etc.). + */ +export type FeedbackSink = (entry: FeedbackEntry) => void | Promise; + +export function truncateForLog( + text: string, + max = FEEDBACK_LOG_PREVIEW_LENGTH, +): { + preview: string; + truncated: boolean; +} { + if (text.length <= max) return { preview: text, truncated: false }; + return { preview: `${text.slice(0, max)}…`, truncated: true }; +} + +/** + * Default sink: structured log line. Intentionally minimal — swap it for a + * real destination by passing your own {@link FeedbackSink}. + */ +const logFeedbackSink: FeedbackSink = (entry) => { + const { preview, truncated } = truncateForLog(entry.message); + console.log( + JSON.stringify({ + event: + entry.kind === "chat_negative" + ? "chat_message_feedback_negative" + : "user_feedback", + org_id: entry.orgId, + user_id: entry.userId, + message_id: entry.messageId, + thread_id: entry.threadId, + reasons: entry.reasons, + message: preview, + message_truncated: truncated, + }), + ); +}; + +type FeedbackBody = { + kind?: unknown; + message?: unknown; + messageId?: unknown; + threadId?: unknown; + reasons?: unknown; + details?: unknown; +}; + +function parseString(value: unknown, maxLen: number): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > maxLen) return null; + return trimmed; +} + +function parseReasons(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const reasons: string[] = []; + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || trimmed.length > 200) continue; + reasons.push(trimmed); + } + return reasons; +} + +export function parseFeedbackBody( + body: FeedbackBody, +): + | { ok: true; entry: Omit } + | { ok: false; error: string } { + const kind = body.kind === "chat_negative" ? "chat_negative" : "general"; + + if (kind === "general") { + const message = parseString(body.message, FEEDBACK_MAX_TEXT_LENGTH); + if (!message) return { ok: false, error: "message required" }; + return { ok: true, entry: { kind: "general", message } }; + } + + const messageId = parseString(body.messageId, 128); + if (!messageId) return { ok: false, error: "messageId required" }; + + const details = + typeof body.details === "string" + ? body.details.trim().slice(0, FEEDBACK_MAX_TEXT_LENGTH) + : ""; + const reasons = parseReasons(body.reasons) ?? []; + + if (reasons.length === 0 && !details) { + return { ok: false, error: "reasons or details required" }; + } + + const threadId = + body.threadId === null || body.threadId === undefined + ? null + : parseString(body.threadId, 128); + + return { + ok: true, + entry: { + kind: "chat_negative", + message: details, + messageId, + threadId, + reasons: reasons.length > 0 ? reasons : undefined, + }, + }; +} + +export function createFeedbackRoutes(sink: FeedbackSink = logFeedbackSink) { + const app = new Hono(); + + app.post( + "/feedback", + bodyLimit({ + maxSize: FEEDBACK_MAX_BODY_SIZE, + onError: (c) => c.json({ error: "Payload too large" }, 413), + }), + async (c) => { + const mesh = c.get("meshContext"); + const userId = mesh.auth.user?.id ?? mesh.auth.apiKey?.userId; + if (!userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const orgId = mesh.organization?.id; + if (!orgId) { + return c.json({ error: "Organization required" }, 400); + } + + let body: FeedbackBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "invalid JSON" }, 400); + } + + const parsed = parseFeedbackBody(body); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + + try { + await sink({ + ...parsed.entry, + orgId, + userId, + }); + } catch (err) { + console.error("[feedback] sink failed:", err); + return c.json({ error: "Failed to record feedback" }, 502); + } + + return c.json({ ok: true }); + }, + ); + + return app; +} diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index 24247aee0f..e23a135377 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -19,6 +19,7 @@ import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; import { createSsoRoutes } from "./org-sso"; import { createProxyRoutes } from "./proxy"; import { createSelfRoutes } from "./self"; +import { createFeedbackRoutes } from "./feedback"; import { createHomeNextActionsRoutes } from "./home-next-actions"; import { createThreadOutputsRoutes } from "./thread-outputs"; import { createTriggerCallbackRoutes } from "./trigger-callback"; @@ -82,6 +83,12 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/", createFileUploadRoutes()); // /api/:org/file-configs/:id/upload app.route("/sandbox", createSandboxRoutes()); // /api/:org/sandbox/:virtualMcpId/:branch/* app.route("/", createHomeNextActionsRoutes()); + // Feedback uses the default log sink. To send feedback somewhere durable + // (Linear, Slack, a DB table, a webhook), pass your own FeedbackSink here — + // see `./feedback.ts`. + // TODO: the final destination (likely Linear) is still being decided — + // wire the chosen sink here once that lands. + app.route("/", createFeedbackRoutes()); app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) app.route( diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 0b27bfb7c0..57569975d6 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -25,6 +25,7 @@ import { File06, Globe01, LogOut01, + MessageChatCircle, Monitor01, Moon01, Plus, @@ -43,6 +44,7 @@ import { authClient } from "@/web/lib/auth-client"; import { track } from "@/web/lib/posthog-client"; import { clearPersistedQueryCache } from "@/web/lib/query-persist"; import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; +import { FeedbackDialog } from "@/web/components/feedback-dialog"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; @@ -534,6 +536,7 @@ export function AccountPopover() { const [open, setOpen] = useState(false); const [creatingOrg, setCreatingOrg] = useState(false); + const [feedbackOpen, setFeedbackOpen] = useState(false); const user = session?.user; const userImage = (user as { image?: string } | undefined)?.image; @@ -570,6 +573,18 @@ export function AccountPopover() { }); }, }, + ...(orgParam + ? [ + { + key: "feedback", + label: "Feedback", + icon: , + onClick: () => { + setFeedbackOpen(true); + }, + } satisfies MenuItem, + ] + : []), { key: "terms", label: "Terms of Use", @@ -732,6 +747,13 @@ export function AccountPopover() { open={creatingOrg} onOpenChange={setCreatingOrg} /> + {orgParam ? ( + + ) : null} ); } diff --git a/apps/mesh/src/web/components/chat/message-feedback-dialog.tsx b/apps/mesh/src/web/components/chat/message-feedback-dialog.tsx new file mode 100644 index 0000000000..d3af87fdfd --- /dev/null +++ b/apps/mesh/src/web/components/chat/message-feedback-dialog.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { toast } from "@deco/ui/components/sonner.js"; +import { track, getSessionReplayUrl } from "@/web/lib/posthog-client"; +import { submitFeedback } from "@/web/lib/submit-feedback"; + +const REASONS = [ + "Incorrect or incomplete", + "Not what I asked for", + "Slow or buggy", + "Style or tone", + "Safety or legal concern", + "Other", +] as const; + +type Reason = (typeof REASONS)[number]; + +interface MessageFeedbackDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + orgSlug: string; + messageId: string; + threadId: string | null; +} + +export function MessageFeedbackDialog({ + open, + onOpenChange, + orgSlug, + messageId, + threadId, +}: MessageFeedbackDialogProps) { + const [selected, setSelected] = useState>(new Set()); + const [details, setDetails] = useState(""); + const [sending, setSending] = useState(false); + + const canSubmit = selected.size > 0 || !!details.trim(); + + const toggle = (reason: Reason) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(reason)) { + next.delete(reason); + } else { + next.add(reason); + } + return next; + }); + }; + + const handleSubmit = async () => { + if (sending || !canSubmit || !orgSlug) return; + setSending(true); + try { + const res = await submitFeedback(orgSlug, { + kind: "chat_negative", + messageId, + threadId, + reasons: selected.size > 0 ? [...selected] : undefined, + details: details.trim() || undefined, + }); + if (!res.ok) { + toast.error("Failed to send feedback. Please try again."); + return; + } + track("chat_message_feedback_negative", { + message_id: messageId, + thread_id: threadId, + reasons: selected.size > 0 ? [...selected] : undefined, + session_replay_url: getSessionReplayUrl(), + }); + setSelected(new Set()); + setDetails(""); + onOpenChange(false); + toast.success("Feedback sent — thank you!"); + } catch { + toast.error("Failed to send feedback. Please try again."); + } finally { + setSending(false); + } + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + setSelected(new Set()); + setDetails(""); + } + onOpenChange(next); + }; + + return ( + + + + + Share feedback + + + +
+ {REASONS.map((reason) => ( + + ))} +
+ +