Skip to content
69 linesCodeBlameRaw
1import { data } from "react-router";
2
3import type { DocThreadAction } from "@g1t/contracts";
4
5import type { Route } from "./+types/threads";
6import { docs } from "../../../lib/services.server";
7import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server";
8
9/**
10 * The editor's comment store (BlockNote's `RESTYjsThreadStore`) writes
11 * here; it reads threads straight from the page's live document, where
12 * the page's room puts what these requests change:
13 *
14 * POST "" start a thread
15 * POST /<thread>/addToDocument anchor it to the selected text
16 * POST /<thread>/comments reply
17 * PUT /<thread>/comments/<comment> edit a comment
18 * DELETE /<thread>/comments/<comment>?soft= delete a comment
19 * DELETE /<thread> delete a thread
20 * POST /<thread>/resolve | /unresolve
21 * POST /<thread>/comments/<comment>/reactions { emoji }
22 * DELETE /<thread>/comments/<comment>/reactions/<emoji>
23 *
24 * A comment on the whole page is a thread started with `page_level: true`.
25 */
26export async function action({ params, context, request }: Route.ActionArgs) {
27 assertSameOrigin(request);
28 const viewer = requireUser(context, request);
29 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
30 const slug = params.owner.toLowerCase();
31 const url = new URL(request.url);
32 const parts = (params["*"] ?? "").split("/").filter(Boolean).map(decodeURIComponent);
33 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
34 const method = request.method.toUpperCase();
35 const [thread, what, comment, sub, emoji] = parts;
36 let action: DocThreadAction | null = null;
37 if (!thread && method === "POST") {
38 const initial = (body.initialComment ?? {}) as { body?: unknown; metadata?: unknown };
39 action = { op: "create", body: initial.body ?? body.body, metadata: body.metadata ?? initial.metadata, page_level: body.page_level === true };
40 } else if (thread && !what && method === "DELETE") action = { op: "delete_thread", thread_id: thread };
41 else if (thread && what === "addToDocument" && method === "POST") {
42 const selection = (body.selection ?? {}) as { yjs?: { anchor?: unknown; head?: unknown } };
43 if (!selection.yjs) return Response.json({ ok: false, error: { code: "invalid", message: "Select some text to comment on." } }, { status: 422 });
44 action = { op: "anchor", thread_id: thread, anchor: selection.yjs.anchor, head: selection.yjs.head };
45 } else if (thread && (what === "resolve" || what === "unresolve") && method === "POST") action = { op: what, thread_id: thread };
46 else if (thread && what === "comments" && !comment && method === "POST") {
47 const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown };
48 action = { op: "comment", thread_id: thread, body: c.body, metadata: c.metadata };
49 } else if (thread && what === "comments" && comment && !sub && method === "PUT") {
50 const c = (body.comment ?? {}) as { body?: unknown; metadata?: unknown };
51 action = { op: "edit_comment", thread_id: thread, comment_id: comment, body: c.body, metadata: c.metadata };
52 } else if (thread && what === "comments" && comment && !sub && method === "DELETE") {
53 action = { op: "delete_comment", thread_id: thread, comment_id: comment, soft: url.searchParams.get("soft") === "true" };
54 } else if (thread && what === "comments" && comment && sub === "reactions" && method === "POST") {
55 action = { op: "react", thread_id: thread, comment_id: comment, emoji: String(body.emoji ?? "") };
56 } else if (thread && what === "comments" && comment && sub === "reactions" && emoji && method === "DELETE") {
57 action = { op: "unreact", thread_id: thread, comment_id: comment, emoji };
58 }
59 if (!action) return Response.json({ ok: false, error: { code: "invalid", message: "Unknown comment request." } }, { status: 404 });
60 try {
61 const result = await docs.thread(slug, params.page, viewer, action);
62 // The thread store reads the answer's body as the thread or comment.
63 if (!result.ok) return Response.json(result, { status: result.error.code === "forbidden" ? 403 : result.error.code === "not_found" ? 404 : 422 });
64 return Response.json(result.value ?? {}, { headers: { "cache-control": "no-store" } });
65 } catch (error) {
66 console.error("docs threads:", error);
67 return Response.json({ ok: false, error: { code: "conflict", message: "Docs didn't answer." } }, { status: 503 });
68 }
69}