| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import type { DocThreadAction } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/threads"; |
| 6 | import { docs } from "../../../lib/services.server"; |
| 7 | import { 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 | */ |
| 26 | export 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 | } |