| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/api"; |
| 6 | import { workspacePeople } from "../../../lib/chat.server"; |
| 7 | import { docs, identity, repos, work } from "../../../lib/services.server"; |
| 8 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; |
| 9 | import { pageIdOf } from "../../../lib/docs"; |
| 10 | |
| 11 | /** |
| 12 | * What Docs pages ask for as they run, as JSON: page search for links and |
| 13 | * the sidebar's search box, a page's history and one version, cards for |
| 14 | * embedded g1t things; and everything they change: pages, spaces, members, |
| 15 | * suggestions, templates. Every answer is `{ ok, value }` or |
| 16 | * `{ ok: false, error }`, the services' own shape. |
| 17 | */ |
| 18 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 19 | const viewer = requireUser(context, request); |
| 20 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 21 | const slug = params.owner.toLowerCase(); |
| 22 | const url = new URL(request.url); |
| 23 | const q = url.searchParams; |
| 24 | const answer = await safely<unknown>(async () => { |
| 25 | if (q.has("search")) return docs.search(slug, viewer, { query: q.get("search") ?? "", space_id: q.get("space"), project: q.get("project"), limit: Number(q.get("limit")) || 10 }); |
| 26 | if (q.get("versions")) return docs.versions(slug, q.get("versions")!, viewer); |
| 27 | if (q.get("version") && q.get("page")) return docs.version(slug, q.get("page")!, q.get("version")!, viewer); |
| 28 | if (q.get("suggestions")) return docs.suggestions(slug, q.get("suggestions")!, viewer); |
| 29 | if (q.get("threads")) return docs.threads(slug, q.get("threads")!, viewer); |
| 30 | if (q.get("space")) return docs.space(slug, q.get("space")!, viewer); |
| 31 | if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin); |
| 32 | if (q.has("templates")) return docs.templates(slug, viewer); |
| 33 | // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page. |
| 34 | if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100)); |
| 35 | // A member's key (`user:<id>`) by username, for adding them to a space. |
| 36 | if (q.get("person")) { |
| 37 | const user = await identity.userByUsername(q.get("person")!.toLowerCase()); |
| 38 | return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } }; |
| 39 | } |
| 40 | return docs.sidebar(slug, viewer); |
| 41 | }); |
| 42 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 43 | } |
| 44 | |
| 45 | type Sent = { |
| 46 | intent?: string; |
| 47 | page_id?: string; |
| 48 | space_id?: string; |
| 49 | suggestion_id?: string; |
| 50 | version_id?: string; |
| 51 | template_id?: string; |
| 52 | member?: string; |
| 53 | role?: DocRole | null; |
| 54 | decision?: "accept" | "reject"; |
| 55 | on?: boolean; |
| 56 | page?: NewDocPage; |
| 57 | change?: DocPageChange; |
| 58 | move?: DocMove; |
| 59 | space?: NewDocSpace; |
| 60 | space_change?: DocSpaceChange; |
| 61 | name?: string; |
| 62 | description?: string | null; |
| 63 | target?: DocEditTarget; |
| 64 | }; |
| 65 | |
| 66 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 67 | assertSameOrigin(request); |
| 68 | const viewer = requireUser(context, request); |
| 69 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 70 | const slug = params.owner.toLowerCase(); |
| 71 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 72 | const page = sent.page_id ?? ""; |
| 73 | const answer = await safely(async (): Promise<Result<unknown>> => { |
| 74 | switch (sent.intent) { |
| 75 | case "create_page": |
| 76 | return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage)); |
| 77 | case "update_page": |
| 78 | return docs.updatePage(slug, page, viewer, sent.change ?? {}); |
| 79 | case "move_page": |
| 80 | return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null }); |
| 81 | case "duplicate_page": |
| 82 | return docs.duplicatePage(slug, page, viewer); |
| 83 | case "archive_page": |
| 84 | return docs.archivePage(slug, page, viewer); |
| 85 | case "restore_page": |
| 86 | return docs.restorePage(slug, page, viewer); |
| 87 | case "delete_page": |
| 88 | return docs.deletePage(slug, page, viewer); |
| 89 | case "favorite": |
| 90 | return docs.favorite(slug, page, viewer, !!sent.on); |
| 91 | case "restore_version": |
| 92 | return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer); |
| 93 | case "decide": |
| 94 | return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject"); |
| 95 | case "accept_all": |
| 96 | return docs.acceptAll(slug, page, viewer); |
| 97 | case "save_template": |
| 98 | return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null }); |
| 99 | case "delete_template": |
| 100 | return docs.deleteTemplate(slug, sent.template_id ?? "", viewer); |
| 101 | case "create_space": |
| 102 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); |
| 103 | case "update_space": |
| 104 | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); |
| 105 | case "set_member": |
| 106 | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); |
| 107 | default: |
| 108 | return { ok: false, error: { code: "invalid", message: "Unknown request." } }; |
| 109 | } |
| 110 | }); |
| 111 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 112 | } |
| 113 | |
| 114 | async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> { |
| 115 | try { |
| 116 | return await run(); |
| 117 | } catch (error) { |
| 118 | console.error("docs api:", error); |
| 119 | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } }; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * The card for a g1t address embedded in a page, as this reader may see |
| 125 | * it: an issue or pull request with its state, a channel, a project, a |
| 126 | * page. Anything they can't read is not found, exactly as one that does |
| 127 | * not exist. |
| 128 | */ |
| 129 | async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> { |
| 130 | let path: string; |
| 131 | try { |
| 132 | const url = new URL(address, origin); |
| 133 | if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } }; |
| 134 | path = url.pathname; |
| 135 | } catch { |
| 136 | return { ok: false, error: { code: "invalid", message: "That isn't an address." } }; |
| 137 | } |
| 138 | const parts = path.split("/").filter(Boolean).map(decodeURIComponent); |
| 139 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } }; |
| 140 | // /<owner>/-/docs/<space>/<page> |
| 141 | if (parts[1] === "-" && parts[2] === "docs" && parts[4]) { |
| 142 | if (parts[0]!.toLowerCase() !== slug) return missing; |
| 143 | const id = pageIdOf(parts[4]); |
| 144 | if (!id) return missing; |
| 145 | const found = await docs.page(slug, id, viewer); |
| 146 | if (!found.ok) return missing; |
| 147 | return { ok: true, value: { kind: "page", title: `${found.value.page.icon ? `${found.value.page.icon} ` : ""}${found.value.page.title || "Untitled"}`, subtitle: found.value.space.name, state: null, href: found.value.page.path } }; |
| 148 | } |
| 149 | // /<owner>/-/chat/<channel> |
| 150 | if (parts[1] === "-" && parts[2] === "chat" && parts[3]) { |
| 151 | return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } }; |
| 152 | } |
| 153 | if (parts.length < 2 || parts[1] === "-") return missing; |
| 154 | const repo = { namespace: parts[0]!, name: parts[1]! }; |
| 155 | // /<owner>/<repo>/issues/<n> and /pull/<n> |
| 156 | const number = Number(parts[3]); |
| 157 | if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) { |
| 158 | if (parts[2] === "issues") { |
| 159 | const found = await work.getIssue(repo, number, viewer); |
| 160 | if (!found.ok) return missing; |
| 161 | return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } }; |
| 162 | } |
| 163 | const found = await work.getPull(repo, number, viewer); |
| 164 | if (!found.ok) return missing; |
| 165 | const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null }; |
| 166 | const state = pull.mergedAt ? "merged" : String(pull.status ?? "open"); |
| 167 | return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } }; |
| 168 | } |
| 169 | const found = await repos.get(repo, viewer); |
| 170 | if (!found.ok) return missing; |
| 171 | return { ok: true, value: { kind: "project", title: `${repo.namespace}/${repo.name}`, subtitle: (found.value as { description?: string | null }).description ?? "Project", state: null, href: `/${repo.namespace}/${repo.name}` } }; |
| 172 | } |
| 173 | |
| 174 | /** Names and faces for member keys, as the workspace knows them. */ |
| 175 | async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> { |
| 176 | const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5)); |
| 177 | const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6))); |
| 178 | const [names, { people, agents }] = await Promise.all([userIds.length ? identity.usernames(userIds).catch(() => ({}) as Record<string, string>) : Promise.resolve({} as Record<string, string>), workspacePeople(slug, viewer)]); |
| 179 | const out = keys.map((key) => { |
| 180 | if (key.startsWith("agent:")) { |
| 181 | const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined; |
| 182 | return { key, kind: "agent", id: key.slice(6), name: agent?.handle ?? "agent", display_name: agent?.display_name ?? "Former agent", avatar: agent?.avatar ?? null, avatar_seed: agent?.avatar_seed ?? null }; |
| 183 | } |
| 184 | const username = names[key.slice(5)]?.toLowerCase(); |
| 185 | const person = username ? people.find((p) => p.name === username) : undefined; |
| 186 | return { key, kind: "user", id: key.slice(5), name: username ?? "ghost", display_name: person?.display_name ?? username ?? "Former member", avatar: person?.avatar ?? null, avatar_seed: null }; |
| 187 | }); |
| 188 | return { ok: true, value: out }; |
| 189 | } |
| 190 | |