diff --git a/apps/web/.env.example b/apps/web/.env.example index 9c7b7dd2..67626ddf 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -69,7 +69,7 @@ AUTH_GOOGLE_SECRET= PAGERDUTY_APP_ID= -SLACK_SUPPORT_WEBHOOK_URL= +SLACK_FEEDBACK_WEBHOOK_URL= WORKSPACES_LOOKBACK_30= WORKSPACES_HIDE_URL= diff --git a/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx index 287e5664..fc9dc97a 100644 --- a/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx +++ b/apps/web/src/app/(docs)/docs/[[...slug]]/page.tsx @@ -14,6 +14,7 @@ import { gitLastModified, validateDocsNav, } from "@/content/docs"; +import { DocsFeedback } from "@/content/docs-feedback"; import { DocsSubNav } from "@/content/docs-sub-nav"; import { TableOfContents } from "@/content/docs-toc"; import { @@ -241,8 +242,9 @@ export default async function DocsPage({ diff --git a/apps/web/src/app/api/feedback/docs/route.ts b/apps/web/src/app/api/feedback/docs/route.ts new file mode 100644 index 00000000..1c0b313a --- /dev/null +++ b/apps/web/src/app/api/feedback/docs/route.ts @@ -0,0 +1,144 @@ +import { redis } from "@openstatus/upstash"; +import { z } from "zod"; + +import { getClientIP, ratelimit } from "@/lib/ratelimit"; +import { hashIP } from "@/lib/utils"; + +export const runtime = "edge"; + +const RATE_LIMIT_WINDOW = 60; // seconds +const MAX_REQUESTS_PER_WINDOW = 5; + +// a docs pathname (from usePathname); constrained so it can't craft arbitrary Redis keys +const path = z + .string() + .min(1) + .max(512) + .regex(/^\/[a-zA-Z0-9/_-]*$/); + +// rating-only (thumbs) and message-only (feedback) are separate actions +const schema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("rating"), + path, + rating: z.enum(["up", "down"]), + }), + z.object({ + kind: z.literal("message"), + path, + message: z.string().trim().min(1).max(2000), + }), +]); + +type Rating = "up" | "down"; + +const countsKey = (path: string) => `docs-feedback:counts:${path}`; +const voterKey = (path: string, id: string) => + `docs-feedback:voter:${path}:${id}`; +// bound voter-key storage; after expiry a re-vote may double count (acceptable) +const VOTER_TTL = 60 * 60 * 24 * 180; // 180 days, seconds + +// Slack mrkdwn treats & < > as control chars; escape user text before interpolation +const escapeSlack = (text: string) => + text.replace(/&/g, "&").replace(//g, ">"); + +export async function POST(request: Request) { + let data: z.infer; + try { + data = schema.parse(await request.json()); + } catch { + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + + const clientIP = getClientIP(request.headers); + if (!clientIP) { + return Response.json( + { error: "Unable to determine client IP" }, + { status: 400 }, + ); + } + const voterId = await hashIP(clientIP); + + const limit = await ratelimit(`docs-feedback:${voterId}`, { + window: RATE_LIMIT_WINDOW, + limit: MAX_REQUESTS_PER_WINDOW, + }); + if (!limit.success) { + return Response.json( + { error: "Too many requests" }, + { + status: 429, + headers: { + "Retry-After": Math.ceil( + (limit.reset - Date.now()) / 1000, + ).toString(), + }, + }, + ); + } + + // dev: skip external writes (Redis tally + Slack) so local runs don't pollute prod + if (process.env.NODE_ENV === "development") { + console.log("docs feedback", data); + return Response.json({ success: true }); + } + + // historical tally of up/down votes per path; best-effort, never blocks Slack + if (data.kind === "rating") { + try { + // one vote per (path, voter); SET ... GET atomically claims the new vote and + // returns the prior one in a single round-trip, so concurrent requests can't + // both read a stale value and double-count. The delta derives from that prior + // value server-side, so a tampered count needs both a real prior vote and the IP. + const previous = (await redis.set( + voterKey(data.path, voterId), + data.rating, + { ex: VOTER_TTL, get: true }, + )) as Rating | null; + if (previous !== data.rating) { + await redis.hincrby(countsKey(data.path), data.rating, 1); + if (previous) { + await redis.hincrby(countsKey(data.path), previous, -1); + } + } + } catch (err) { + console.error("Docs feedback: failed to record vote", err); + } + } + + const webhook = process.env.SLACK_FEEDBACK_WEBHOOK_URL; + if (!webhook) { + console.error("Docs feedback: SLACK_FEEDBACK_WEBHOOK_URL not configured."); + return Response.json({ success: true }); + } + + const lines = [ + data.kind === "rating" + ? `*Docs feedback:* ${data.rating === "up" ? "👍 helpful" : "👎 not helpful"}` + : "*Docs feedback:* 💬 comment", + `*Path:* ${escapeSlack(data.path)}`, + ]; + if (data.kind === "message") { + lines.push( + "--------------------------------", + `*Message:* ${escapeSlack(data.message)}`, + ); + } + + try { + const response = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: lines.join("\n") }), + }); + if (!response.ok) { + console.error( + `Docs feedback: Slack webhook responded ${response.status}`, + ); + } + } catch (err) { + console.error("Docs feedback: failed to post to Slack", err); + } + + return Response.json({ success: true }); +} diff --git a/apps/web/src/content/docs-feedback.tsx b/apps/web/src/content/docs-feedback.tsx new file mode 100644 index 00000000..c57310f8 --- /dev/null +++ b/apps/web/src/content/docs-feedback.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@openstatus/ui/components/ui/form"; +import { Kbd } from "@openstatus/ui/components/ui/kbd"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@openstatus/ui/components/ui/popover"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { Inbox, LoaderCircle } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { toastAction } from "@/lib/toast"; + +type Rating = "up" | "down"; + +const ratingKey = (path: string) => `docs-rating:${path}`; + +const schema = z.object({ + message: z.string().trim().min(1).max(2000), +}); + +type FeedbackBody = + | { kind: "rating"; path: string; rating: Rating; previous?: Rating } + | { kind: "message"; path: string; message: string }; + +// never throws; returns whether the server accepted the submission +async function postFeedback(body: FeedbackBody): Promise { + try { + const res = await fetch("/api/feedback/docs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.ok; + } catch { + return false; + } +} + +function DocsFeedbackBar({ path }: { path: string }) { + const [rating, setRating] = useState(); + const [open, setOpen] = useState(false); + const [sent, setSent] = useState(false); + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { message: "" }, + }); + + // rehydrate a prior vote so the chosen arrow stays highlighted across loads + useEffect(() => { + const stored = window.localStorage.getItem(ratingKey(path)); + if (stored === "up" || stored === "down") setRating(stored); + }, [path]); + + // reset the popover contents shortly after it closes (300ms = close anim) + useEffect(() => { + if (!open && sent) { + const t = setTimeout(() => { + setSent(false); + form.reset(); + }, 300); + return () => clearTimeout(t); + } + }, [open, sent, form]); + + function rate(value: Rating) { + // read the prior vote from localStorage (not state) so a click before the + // hydration effect still reports the correct `previous` to the server + const stored = window.localStorage.getItem(ratingKey(path)); + const previous = stored === "up" || stored === "down" ? stored : undefined; + if (previous === value) return; + setRating(value); + window.localStorage.setItem(ratingKey(path), value); + void postFeedback({ kind: "rating", path, rating: value, previous }); + } + + async function onSubmit(values: z.infer) { + const ok = await postFeedback({ + kind: "message", + path, + message: values.message, + }); + if (ok) { + setSent(true); + } else { + toastAction("error"); + } + } + + return ( +
+

+ Was this helpful? +

+ +
+ + + + + +
+ + {sent ? ( +
+ +

+ Thanks for sharing! +

+

+ We read every note. +

+
+ ) : ( +
+ + ( + + Feedback + +