diff --git a/.github/workflows/docs-negotiation.yml b/.github/workflows/docs-negotiation.yml index 541d5af8e3f..64b55a001c7 100644 --- a/.github/workflows/docs-negotiation.yml +++ b/.github/workflows/docs-negotiation.yml @@ -226,9 +226,16 @@ jobs: if: always() && steps.build.outcome == 'success' run: | mkdir -p apps/docs/test-results + FEEDBACK_TLS_DIR=$(mktemp -d) + export DOCS_FEEDBACK_TEST_CERT="$FEEDBACK_TLS_DIR/cert.pem" + export DOCS_FEEDBACK_TEST_KEY="$FEEDBACK_TLS_DIR/key.pem" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' -keyout "$DOCS_FEEDBACK_TEST_KEY" -out "$DOCS_FEEDBACK_TEST_CERT" 2>/dev/null + export NODE_EXTRA_CA_CERTS="$DOCS_FEEDBACK_TEST_CERT" + export DOCS_FEEDBACK_WEBHOOK_URL=https://127.0.0.1:3443/feedback + export DOCS_FEEDBACK_WEBHOOK_TOKEN=$(openssl rand -hex 32) bun run --cwd apps/docs start --port 3001 >apps/docs/test-results/docs-server.log 2>&1 & DOCS_SERVER_PID=$! - trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true' EXIT + trap 'kill "$DOCS_SERVER_PID" 2>/dev/null || true; rm -rf "$FEEDBACK_TLS_DIR"' EXIT STATUS=0 bun run --cwd apps/docs test:negotiation:e2e || STATUS=1 bun run --cwd apps/docs scripts/test-docs-interfaces-e2e.ts || STATUS=1 diff --git a/apps/docs/DOMAIN_MOVE.md b/apps/docs/DOMAIN_MOVE.md index bbd479bcc00..6085e1690fd 100644 --- a/apps/docs/DOMAIN_MOVE.md +++ b/apps/docs/DOMAIN_MOVE.md @@ -2,6 +2,8 @@ The site currently targets `docs.sim.ai`. A move to `sim.ai/docs` is separate infrastructure work, not completed by the docs redesign. Changing `NEXT_PUBLIC_DOCS_URL` alone is insufficient. +The platform's temporary 307 redirects send `/docs` to the introduction and `/docs/*` to the matching path on `docs.sim.ai`, preserving query parameters. Remove these outgoing redirects when mounting the docs at `/docs`; they are not the routing layer for the future move. + ## Required changes - Build Next.js with `basePath: '/docs'`; an asset prefix alone does not mount the app. Keep canonical catalog IDs such as `/introduction` independent of the public prefix. diff --git a/apps/docs/README.md b/apps/docs/README.md index 4733ee045c2..59428b42b94 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -29,3 +29,16 @@ The [machine access guide](MACHINE_ACCESS.md) documents Markdown, JSON, MCP, sea From the repository root, run the required lint, type, audit, and test gates in the development guidelines. From this directory, `bun run type-check` compiles MDX and checks the docs workspace, and `bun run build` validates the production site. Inspect affected page types on desktop and a narrow viewport, and check internal links and existing anchors after heading changes. The docs HTTP workflow also checks sidebar navigation, API language switching, and copying Markdown on desktop and mobile layouts. It uploads screenshots and a JSON report with the HTTP regression artifacts. These checks run against the built site in CI; no visitor telemetry is collected. Use Vercel's build logs and usage reports to compare deployment cost and duration. + +## Reader feedback + +Votes are submitted through `/api/feedback` to an authenticated Sim webhook. Configure +`DOCS_FEEDBACK_WEBHOOK_URL` and `DOCS_FEEDBACK_WEBHOOK_TOKEN` only on the server. The +endpoint returns unavailable when either is absent. Limit POST requests to this path +at the deployment edge before enabling it (10 requests per IP per minute). + +The workflow receives `submission_id`, `page`, `helpful`, `comment`, and `received_at`. +Use `submission_id` as the webhook deduplication field and a unique table column for +upsert. Retries of an unchanged submission reuse its ID. The acknowledgment confirms +queue acceptance; workflow failures remain visible in Sim execution logs. No email, +IP address, or user identity is included in the stored feedback payload. diff --git a/apps/docs/app/api/feedback/route.ts b/apps/docs/app/api/feedback/route.ts new file mode 100644 index 00000000000..7f156870d5e --- /dev/null +++ b/apps/docs/app/api/feedback/route.ts @@ -0,0 +1,95 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { getPage } from '@/lib/content/catalog' +import { pagePathSchema } from '@/lib/http/pages' + +const logger = createLogger('DocsFeedback') +const feedbackSchema = z + .object({ + submissionId: z.uuid(), + page: pagePathSchema, + helpful: z.boolean(), + comment: z.string().trim().max(2000), + }) + .strict() +const MAX_BODY_BYTES = 12_288 + +export async function POST(request: Request) { + const headers = { 'Cache-Control': 'no-store' } + const fail = (error: string, status: number) => Response.json({ error }, { status, headers }) + const requestUrl = new URL(request.url) + const host = request.headers.get('host') + if (!host || request.headers.get('origin') !== `${requestUrl.protocol}//${host}`) { + return fail('Feedback must be submitted from this site.', 403) + } + if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') { + return fail('Expected JSON.', 415) + } + const reader = request.body?.getReader() + if (!reader) return fail('Feedback is required.', 400) + let text = '' + let bytes = 0 + const decoder = new TextDecoder() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > MAX_BODY_BYTES) { + await reader.cancel() + return fail('Feedback is too long.', 413) + } + text += decoder.decode(chunk.value, { stream: true }) + } + text += decoder.decode() + } catch { + return fail('Could not read feedback.', 400) + } finally { + reader.releaseLock() + } + let input: z.infer + try { + input = feedbackSchema.parse(JSON.parse(text)) + } catch { + return fail('Check your feedback and try again.', 400) + } + const page = getPage(input.page.replace(/\/$/, '') || '/') + if (!page || page.url === '/') return fail('Documentation page not found.', 404) + const endpoint = process.env.DOCS_FEEDBACK_WEBHOOK_URL + const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN + if (!endpoint || !token) return fail('Feedback is temporarily unavailable.', 503) + try { + const url = new URL(endpoint) + if (url.protocol !== 'https:') throw new Error('Invalid feedback configuration') + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + submission_id: input.submissionId, + page: page.url, + helpful: input.helpful, + comment: input.comment, + received_at: new Date().toISOString(), + }), + signal: AbortSignal.timeout(15_000), + redirect: 'error', + }) + if (!response.ok) { + logger.warn('Feedback intake rejected submission', { status: response.status }) + return fail('Could not send feedback. Please try again.', 502) + } + const receipt: unknown = await response.json() + if ( + !receipt || + typeof receipt !== 'object' || + !('message' in receipt) || + receipt.message !== 'Webhook processed' + ) { + return fail('Could not confirm feedback. Please try again.', 502) + } + return Response.json({ accepted: true }, { status: 202, headers }) + } catch { + logger.warn('Feedback intake unavailable') + return fail('Could not send feedback. Please try again.', 503) + } +} diff --git a/apps/docs/components/docs/article.tsx b/apps/docs/components/docs/article.tsx index 01cc048e327..09b9e35a0f3 100644 --- a/apps/docs/components/docs/article.tsx +++ b/apps/docs/components/docs/article.tsx @@ -1,13 +1,13 @@ import type { ReactNode } from 'react' -import { ArrowLeft, ArrowRight, SquareArrowUpRight, ThumbsDown, ThumbsUp } from '@sim/emcn/icons' +import { ArrowLeft, ArrowRight, SquareArrowUpRight } from '@sim/emcn/icons' import Link from 'next/link' import { ContentInteractions } from '@/components/docs/content-interactions' +import { Feedback } from '@/components/docs/feedback' import { PageActions } from '@/components/docs/page-actions' import { IntegrationIcon } from '@/components/ui/integration-icon' import { getNavigation, getNeighbours } from '@/lib/content/catalog' import { findNavigationGroup } from '@/lib/content/navigation' import type { DocsPage } from '@/lib/content/types' -import { DOCS_BASE_URL } from '@/lib/urls' interface ArticleProps { page: DocsPage @@ -24,8 +24,6 @@ export function Article({ page, children, aside }: ArticleProps) { ? `apps/sim/lib/api/contracts/v2` : `apps/docs/content/docs/${page.sourcePath}` const editUrl = `https://github.com/simstudioai/sim/${page.operation ? 'tree' : 'edit'}/staging/${source}` - const feedbackUrl = (helpful: boolean) => - `https://github.com/simstudioai/sim/issues/new?${new URLSearchParams({ title: `Docs feedback: ${page.title}`, body: `Page: ${DOCS_BASE_URL}${page.url}\n\n${helpful ? 'What helped:' : 'What could be improved:'}\n` })}` return (
{children}
-
- Was this helpful? - - - - - - -
+ Suggest an edit diff --git a/apps/docs/components/docs/feedback.tsx b/apps/docs/components/docs/feedback.tsx new file mode 100644 index 00000000000..4b5448bf3cd --- /dev/null +++ b/apps/docs/components/docs/feedback.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useRef, useState } from 'react' +import { ThumbsDown, ThumbsUp } from '@sim/emcn/icons' +import { generateId } from '@sim/utils/id' +import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query' +import { DocsButton } from '@/components/docs/primitives' + +interface FeedbackProps { + page: string +} + +export function Feedback({ page }: FeedbackProps) { + const [client] = useState(() => new QueryClient()) + return ( + + + + ) +} + +function FeedbackForm({ page }: FeedbackProps) { + const [helpful, setHelpful] = useState(null) + const [comment, setComment] = useState('') + const pendingSubmission = useRef<{ + submissionId: string + helpful: boolean + comment: string + } | null>(null) + const submission = useMutation({ + retry: false, + mutationFn: async (payload: { submissionId: string; helpful: boolean; comment: string }) => { + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, page }), + signal: AbortSignal.timeout(20_000), + }) + if (!response.ok) + throw new Error( + response.status === 429 + ? 'Too many submissions. Please try again in a minute.' + : 'Could not send feedback. Please try again.' + ) + const result: unknown = await response.json() + if ( + !result || + typeof result !== 'object' || + !('accepted' in result) || + result.accepted !== true + ) { + throw new Error('Could not confirm feedback. Please try again.') + } + }, + }) + function submit(vote: boolean) { + if (submission.isPending) return + const text = vote ? '' : comment.trim() + if ( + !pendingSubmission.current || + pendingSubmission.current.helpful !== vote || + pendingSubmission.current.comment !== text + ) { + pendingSubmission.current = { submissionId: generateId(), helpful: vote, comment: text } + } + submission.mutate(pendingSubmission.current) + } + if (submission.isSuccess) return

Thanks for your feedback.

+ return ( +
{ + event.preventDefault() + if (helpful !== null && !submission.isPending) submit(helpful) + }} + > +
+ Was this helpful? + { + setHelpful(true) + submission.reset() + submit(true) + }} + > + + + { + setHelpful(false) + submission.reset() + }} + > + + +
+ {helpful === false && ( +
+ +