Skip to content

Commit 8db5f61

Browse files
committed
improvement(docs): collect reader feedback inline
1 parent 20fb744 commit 8db5f61

7 files changed

Lines changed: 321 additions & 23 deletions

File tree

‎apps/docs/DOMAIN_MOVE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
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.
44

5+
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.
6+
57
## Required changes
68

79
- 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.

‎apps/docs/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,16 @@ The [machine access guide](MACHINE_ACCESS.md) documents Markdown, JSON, MCP, sea
2929
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.
3030

3131
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.
32+
33+
## Reader feedback
34+
35+
Votes are submitted through `/api/feedback` to an authenticated Sim webhook. Configure
36+
`DOCS_FEEDBACK_WEBHOOK_URL` and `DOCS_FEEDBACK_WEBHOOK_TOKEN` only on the server. The
37+
endpoint returns unavailable when either is absent. Limit POST requests to this path
38+
at the deployment edge before enabling it (10 requests per IP per minute).
39+
40+
The workflow receives `submission_id`, `page`, `helpful`, `comment`, and `received_at`.
41+
Use `submission_id` as the webhook deduplication field and a unique table column for
42+
upsert. Retries of an unchanged submission reuse its ID. The acknowledgment confirms
43+
queue acceptance; workflow failures remain visible in Sim execution logs. No email,
44+
IP address, or user identity is included in the stored feedback payload.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { createLogger } from '@sim/logger'
2+
import { z } from 'zod'
3+
import { getPage } from '@/lib/content/catalog'
4+
import { pagePathSchema } from '@/lib/http/pages'
5+
6+
const logger = createLogger('DocsFeedback')
7+
const feedbackSchema = z
8+
.object({
9+
submissionId: z.uuid(),
10+
page: pagePathSchema,
11+
helpful: z.boolean(),
12+
comment: z.string().trim().max(2000),
13+
})
14+
.strict()
15+
const MAX_BODY_BYTES = 12_288
16+
17+
export async function POST(request: Request) {
18+
const headers = { 'Cache-Control': 'no-store' }
19+
const fail = (error: string, status: number) => Response.json({ error }, { status, headers })
20+
if (request.headers.get('origin') !== new URL(request.url).origin) {
21+
return fail('Feedback must be submitted from this site.', 403)
22+
}
23+
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') {
24+
return fail('Expected JSON.', 415)
25+
}
26+
const reader = request.body?.getReader()
27+
if (!reader) return fail('Feedback is required.', 400)
28+
let text = ''
29+
let bytes = 0
30+
const decoder = new TextDecoder()
31+
try {
32+
while (true) {
33+
const chunk = await reader.read()
34+
if (chunk.done) break
35+
bytes += chunk.value.byteLength
36+
if (bytes > MAX_BODY_BYTES) {
37+
await reader.cancel()
38+
return fail('Feedback is too long.', 413)
39+
}
40+
text += decoder.decode(chunk.value, { stream: true })
41+
}
42+
text += decoder.decode()
43+
} catch {
44+
return fail('Could not read feedback.', 400)
45+
} finally {
46+
reader.releaseLock()
47+
}
48+
let input: z.infer<typeof feedbackSchema>
49+
try {
50+
input = feedbackSchema.parse(JSON.parse(text))
51+
} catch {
52+
return fail('Check your feedback and try again.', 400)
53+
}
54+
const page = getPage(input.page)
55+
if (!page || page.url === '/') return fail('Documentation page not found.', 404)
56+
const endpoint = process.env.DOCS_FEEDBACK_WEBHOOK_URL
57+
const token = process.env.DOCS_FEEDBACK_WEBHOOK_TOKEN
58+
if (!endpoint || !token) return fail('Feedback is temporarily unavailable.', 503)
59+
try {
60+
const url = new URL(endpoint)
61+
if (url.protocol !== 'https:') throw new Error('Invalid feedback configuration')
62+
const response = await fetch(url, {
63+
method: 'POST',
64+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
65+
body: JSON.stringify({
66+
submission_id: input.submissionId,
67+
page: page.url,
68+
helpful: input.helpful,
69+
comment: input.comment,
70+
received_at: new Date().toISOString(),
71+
}),
72+
signal: AbortSignal.timeout(15_000),
73+
redirect: 'error',
74+
})
75+
if (!response.ok) {
76+
logger.warn('Feedback intake rejected submission', { status: response.status })
77+
return fail('Could not send feedback. Please try again.', 502)
78+
}
79+
const receipt: unknown = await response.json()
80+
if (
81+
!receipt ||
82+
typeof receipt !== 'object' ||
83+
!('message' in receipt) ||
84+
receipt.message !== 'Webhook processed'
85+
) {
86+
return fail('Could not confirm feedback. Please try again.', 502)
87+
}
88+
return Response.json({ accepted: true }, { status: 202, headers })
89+
} catch {
90+
logger.warn('Feedback intake unavailable')
91+
return fail('Could not send feedback. Please try again.', 503)
92+
}
93+
}

‎apps/docs/components/docs/article.tsx‎

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { ReactNode } from 'react'
2-
import { ArrowLeft, ArrowRight, SquareArrowUpRight, ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
2+
import { ArrowLeft, ArrowRight, SquareArrowUpRight } from '@sim/emcn/icons'
33
import Link from 'next/link'
44
import { ContentInteractions } from '@/components/docs/content-interactions'
5+
import { Feedback } from '@/components/docs/feedback'
56
import { PageActions } from '@/components/docs/page-actions'
67
import { IntegrationIcon } from '@/components/ui/integration-icon'
78
import { getNavigation, getNeighbours } from '@/lib/content/catalog'
89
import { findNavigationGroup } from '@/lib/content/navigation'
910
import type { DocsPage } from '@/lib/content/types'
10-
import { DOCS_BASE_URL } from '@/lib/urls'
1111

1212
interface ArticleProps {
1313
page: DocsPage
@@ -24,8 +24,6 @@ export function Article({ page, children, aside }: ArticleProps) {
2424
? `apps/sim/lib/api/contracts/v2`
2525
: `apps/docs/content/docs/${page.sourcePath}`
2626
const editUrl = `https://github.com/simstudioai/sim/${page.operation ? 'tree' : 'edit'}/staging/${source}`
27-
const feedbackUrl = (helpful: boolean) =>
28-
`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` })}`
2927
return (
3028
<main
3129
id='main-content'
@@ -53,25 +51,7 @@ export function Article({ page, children, aside }: ArticleProps) {
5351
</header>
5452
<article className='min-w-0'>{children}</article>
5553
<div className='mt-12 flex flex-wrap items-center justify-between gap-4 border-[var(--docs-border)] border-t pt-5 text-[var(--docs-muted)] text-docs-caption'>
56-
<div className='flex items-center gap-2'>
57-
<span>Was this helpful?</span>
58-
<a
59-
href={feedbackUrl(true)}
60-
aria-label='Share positive feedback on GitHub'
61-
title='Share feedback on GitHub'
62-
className='before:-inset-2 relative flex size-7 items-center justify-center rounded-full before:absolute hover:bg-[var(--docs-hover)]'
63-
>
64-
<ThumbsUp className='size-4' />
65-
</a>
66-
<a
67-
href={feedbackUrl(false)}
68-
aria-label='Suggest an improvement on GitHub'
69-
title='Suggest an improvement on GitHub'
70-
className='before:-inset-2 relative flex size-7 items-center justify-center rounded-full before:absolute hover:bg-[var(--docs-hover)]'
71-
>
72-
<ThumbsDown className='size-4' />
73-
</a>
74-
</div>
54+
<Feedback key={page.url} page={page.url} />
7555
<a href={editUrl} className='flex items-center gap-1.5 hover:text-[var(--docs-heading)]'>
7656
Suggest an edit
7757
<SquareArrowUpRight className='size-4' />
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use client'
2+
3+
import { useRef, useState } from 'react'
4+
import { ThumbsDown, ThumbsUp } from '@sim/emcn/icons'
5+
import { generateId } from '@sim/utils/id'
6+
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
7+
import { DocsButton } from '@/components/docs/primitives'
8+
9+
interface FeedbackProps {
10+
page: string
11+
}
12+
13+
export function Feedback({ page }: FeedbackProps) {
14+
const [client] = useState(() => new QueryClient())
15+
return (
16+
<QueryClientProvider client={client}>
17+
<FeedbackForm key={page} page={page} />
18+
</QueryClientProvider>
19+
)
20+
}
21+
22+
function FeedbackForm({ page }: FeedbackProps) {
23+
const [helpful, setHelpful] = useState<boolean | null>(null)
24+
const [comment, setComment] = useState('')
25+
const pendingSubmission = useRef<{
26+
submissionId: string
27+
helpful: boolean
28+
comment: string
29+
} | null>(null)
30+
const submission = useMutation({
31+
retry: false,
32+
mutationFn: async (payload: { submissionId: string; helpful: boolean; comment: string }) => {
33+
const response = await fetch('/api/feedback', {
34+
method: 'POST',
35+
headers: { 'Content-Type': 'application/json' },
36+
body: JSON.stringify({ ...payload, page }),
37+
signal: AbortSignal.timeout(20_000),
38+
})
39+
if (!response.ok)
40+
throw new Error(
41+
response.status === 429
42+
? 'Too many submissions. Please try again in a minute.'
43+
: 'Could not send feedback. Please try again.'
44+
)
45+
const result: unknown = await response.json()
46+
if (
47+
!result ||
48+
typeof result !== 'object' ||
49+
!('accepted' in result) ||
50+
result.accepted !== true
51+
) {
52+
throw new Error('Could not confirm feedback. Please try again.')
53+
}
54+
},
55+
})
56+
function submit(vote: boolean) {
57+
if (submission.isPending) return
58+
const text = vote ? '' : comment.trim()
59+
if (
60+
!pendingSubmission.current ||
61+
pendingSubmission.current.helpful !== vote ||
62+
pendingSubmission.current.comment !== text
63+
) {
64+
pendingSubmission.current = { submissionId: generateId(), helpful: vote, comment: text }
65+
}
66+
submission.mutate(pendingSubmission.current)
67+
}
68+
if (submission.isSuccess) return <p role='status'>Thanks for your feedback.</p>
69+
return (
70+
<form
71+
className='min-w-0 space-y-3'
72+
onSubmit={(event) => {
73+
event.preventDefault()
74+
if (helpful !== null && !submission.isPending) submit(helpful)
75+
}}
76+
>
77+
<div className='flex items-center gap-2'>
78+
<span>Was this helpful?</span>
79+
<DocsButton
80+
type='button'
81+
aria-label='Yes, this was helpful'
82+
aria-pressed={helpful === true}
83+
disabled={submission.isPending}
84+
iconOnly
85+
className='size-11 aria-pressed:bg-[var(--docs-selected)]'
86+
onClick={() => {
87+
setHelpful(true)
88+
submission.reset()
89+
submit(true)
90+
}}
91+
>
92+
<ThumbsUp className='size-4' />
93+
</DocsButton>
94+
<DocsButton
95+
type='button'
96+
aria-label='No, this could be improved'
97+
aria-pressed={helpful === false}
98+
disabled={submission.isPending}
99+
iconOnly
100+
className='size-11 aria-pressed:bg-[var(--docs-selected)]'
101+
onClick={() => {
102+
setHelpful(false)
103+
submission.reset()
104+
}}
105+
>
106+
<ThumbsDown className='size-4' />
107+
</DocsButton>
108+
</div>
109+
{helpful === false && (
110+
<div className='space-y-2'>
111+
<label className='block' htmlFor='docs-feedback-comment'>
112+
What could be better? (optional)
113+
</label>
114+
<textarea
115+
id='docs-feedback-comment'
116+
aria-describedby='docs-feedback-privacy'
117+
value={comment}
118+
maxLength={2000}
119+
rows={3}
120+
disabled={submission.isPending}
121+
onChange={(event) => setComment(event.target.value)}
122+
className='w-full min-w-0 resize-y rounded-lg border border-[var(--docs-border)] bg-[var(--docs-canvas)] p-3 text-[var(--docs-heading)] text-docs-body focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2'
123+
placeholder='Tell us what was missing or unclear.'
124+
/>
125+
<p id='docs-feedback-privacy'>Please don’t include private information.</p>
126+
<DocsButton
127+
type='submit'
128+
disabled={submission.isPending}
129+
className='h-11 border border-[var(--docs-border)]'
130+
>
131+
{submission.isPending ? 'Sending…' : 'Send feedback'}
132+
</DocsButton>
133+
</div>
134+
)}
135+
{submission.isPending && helpful === true && <p role='status'>Sending…</p>}
136+
{submission.isError && <p role='alert'>{submission.error.message}</p>}
137+
</form>
138+
)
139+
}

‎apps/docs/scripts/test-docs-interfaces-e2e.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dirname } from 'node:path'
33
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
44
import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
6+
import { generateId } from '@sim/utils/id'
67
import { toRecord } from '@sim/utils/object'
78
import routes from '@/.content/routes.json'
89

@@ -51,6 +52,63 @@ const paths = [
5152
'/cli',
5253
'/mcp/overview',
5354
]
55+
const feedback = {
56+
submissionId: generateId(),
57+
page: '/introduction',
58+
helpful: true,
59+
comment: '',
60+
}
61+
for (const example of [
62+
{
63+
name: 'foreign origin',
64+
origin: 'https://example.com',
65+
contentType: 'application/json',
66+
body: JSON.stringify(feedback),
67+
status: 403,
68+
},
69+
{
70+
name: 'non-JSON content',
71+
origin: base.origin,
72+
contentType: 'text/plain',
73+
body: JSON.stringify(feedback),
74+
status: 415,
75+
},
76+
{
77+
name: 'oversized body',
78+
origin: base.origin,
79+
contentType: 'application/json',
80+
body: JSON.stringify({ ...feedback, comment: 'x'.repeat(13000) }),
81+
status: 413,
82+
},
83+
{
84+
name: 'invalid vote',
85+
origin: base.origin,
86+
contentType: 'application/json',
87+
body: JSON.stringify({ ...feedback, helpful: 'yes' }),
88+
status: 400,
89+
},
90+
{
91+
name: 'unknown page',
92+
origin: base.origin,
93+
contentType: 'application/json',
94+
body: JSON.stringify({ ...feedback, page: '/no-such-document' }),
95+
status: 404,
96+
},
97+
]) {
98+
await check(`Feedback rejects ${example.name} before forwarding`, async () => {
99+
const response = await get('/api/feedback', {
100+
method: 'POST',
101+
headers: { Origin: example.origin, 'Content-Type': example.contentType },
102+
body: example.body,
103+
})
104+
assert(response.status === example.status, `Expected ${example.status}, got ${response.status}`)
105+
assert(
106+
response.headers.get('Cache-Control') === 'no-store',
107+
'Feedback response must not be cached'
108+
)
109+
})
110+
}
111+
54112
for (const path of paths) {
55113
await check(`Complete JSON and Markdown representations agree: ${path}`, async () => {
56114
const json = await get(`/api/docs/page?path=${encodeURIComponent(path)}`)

0 commit comments

Comments
 (0)