| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import type { DocEditTarget, DocMove, DocPageChange, DocRole, DocSpaceChange, NewDocPage, NewDocSpace, Result, User } 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 | if (q.has("stale")) return docs.stalePages(slug, viewer, { repo: q.get("stale") || null }); |
| 34 | // Repositories to cite, describe or show the docs of: the workspace's that the viewer can read. |
| 35 | if (q.has("repos")) return repositories(slug, viewer); |
| 36 | // A citation's commit: the default branch's head, once the path is there. |
| 37 | if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? ""); |
| 38 | // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on a page. |
| 39 | if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100)); |
| 40 | // A member's key (`user:<id>`) by username, for adding them to a space. |
| 41 | if (q.get("person")) { |
| 42 | const user = await identity.userByUsername(q.get("person")!.toLowerCase()); |
| 43 | return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } }; |
| 44 | } |
| 45 | return docs.sidebar(slug, viewer); |
| 46 | }); |
| 47 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 48 | } |
| 49 | |
| 50 | type Sent = { |
| 51 | intent?: string; |
| 52 | page_id?: string; |
| 53 | space_id?: string; |
| 54 | suggestion_id?: string; |
| 55 | version_id?: string; |
| 56 | template_id?: string; |
| 57 | member?: string; |
| 58 | role?: DocRole | null; |
| 59 | decision?: "accept" | "reject"; |
| 60 | on?: boolean; |
| 61 | page?: NewDocPage; |
| 62 | change?: DocPageChange; |
| 63 | move?: DocMove; |
| 64 | space?: NewDocSpace; |
| 65 | space_change?: DocSpaceChange; |
| 66 | name?: string; |
| 67 | description?: string | null; |
| 68 | target?: DocEditTarget; |
| 69 | repo?: string; |
| 70 | id?: string; |
| 71 | }; |
| 72 | |
| 73 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 74 | assertSameOrigin(request); |
| 75 | const viewer = requireUser(context, request); |
| 76 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 77 | const slug = params.owner.toLowerCase(); |
| 78 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 79 | const page = sent.page_id ?? ""; |
| 80 | const answer = await safely(async (): Promise<Result<unknown>> => { |
| 81 | switch (sent.intent) { |
| 82 | case "create_page": |
| 83 | return docs.createPage(slug, viewer, sent.page ?? ({ space_id: sent.space_id ?? "" } as NewDocPage)); |
| 84 | case "update_page": |
| 85 | return docs.updatePage(slug, page, viewer, sent.change ?? {}); |
| 86 | case "move_page": |
| 87 | return docs.movePage(slug, page, viewer, sent.move ?? { parent_id: null }); |
| 88 | case "duplicate_page": |
| 89 | return docs.duplicatePage(slug, page, viewer); |
| 90 | case "archive_page": |
| 91 | return docs.archivePage(slug, page, viewer); |
| 92 | case "restore_page": |
| 93 | return docs.restorePage(slug, page, viewer); |
| 94 | case "delete_page": |
| 95 | return docs.deletePage(slug, page, viewer); |
| 96 | case "favorite": |
| 97 | return docs.favorite(slug, page, viewer, !!sent.on); |
| 98 | case "restore_version": |
| 99 | return docs.restoreVersion(slug, page, sent.version_id ?? "", viewer); |
| 100 | case "decide": |
| 101 | return docs.decideSuggestion(slug, sent.suggestion_id ?? "", viewer, sent.decision === "accept" ? "accept" : "reject"); |
| 102 | case "accept_all": |
| 103 | return docs.acceptAll(slug, page, viewer); |
| 104 | case "save_template": |
| 105 | return docs.saveTemplate(slug, viewer, { page_id: page, name: sent.name ?? "", description: sent.description ?? null }); |
| 106 | case "delete_template": |
| 107 | return docs.deleteTemplate(slug, sent.template_id ?? "", viewer); |
| 108 | case "create_space": |
| 109 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); |
| 110 | case "update_space": |
| 111 | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); |
| 112 | case "set_member": |
| 113 | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); |
| 114 | case "mark_current": |
| 115 | return docs.markCurrent(slug, page, viewer); |
| 116 | case "add_repo_space": |
| 117 | return docs.addRepoSpace(slug, viewer, sent.repo ?? ""); |
| 118 | case "remove_repo_space": |
| 119 | return docs.removeRepoSpace(slug, viewer, sent.id ?? ""); |
| 120 | default: |
| 121 | return { ok: false, error: { code: "invalid", message: "Unknown request." } }; |
| 122 | } |
| 123 | }); |
| 124 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 125 | } |
| 126 | |
| 127 | async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> { |
| 128 | try { |
| 129 | return await run(); |
| 130 | } catch (error) { |
| 131 | console.error("docs api:", error); |
| 132 | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Try again in a moment." } }; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * The card for a g1t address embedded in a page, as this reader may see |
| 138 | * it: an issue or pull request with its state, a channel, a project, a |
| 139 | * page. Anything they can't read is not found, exactly as one that does |
| 140 | * not exist. |
| 141 | */ |
| 142 | async function embed(slug: string, address: string, viewer: Parameters<typeof docs.page>[2], origin: string): Promise<Result<unknown>> { |
| 143 | let path: string; |
| 144 | try { |
| 145 | const url = new URL(address, origin); |
| 146 | if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } }; |
| 147 | path = url.pathname; |
| 148 | } catch { |
| 149 | return { ok: false, error: { code: "invalid", message: "That isn't an address." } }; |
| 150 | } |
| 151 | const parts = path.split("/").filter(Boolean).map(decodeURIComponent); |
| 152 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } }; |
| 153 | // /<owner>/-/docs/<space>/<page> |
| 154 | if (parts[1] === "-" && parts[2] === "docs" && parts[4]) { |
| 155 | if (parts[0]!.toLowerCase() !== slug) return missing; |
| 156 | const id = pageIdOf(parts[4]); |
| 157 | if (!id) return missing; |
| 158 | const found = await docs.page(slug, id, viewer); |
| 159 | if (!found.ok) return missing; |
| 160 | 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 } }; |
| 161 | } |
| 162 | // /<owner>/-/chat/<channel> |
| 163 | if (parts[1] === "-" && parts[2] === "chat" && parts[3]) { |
| 164 | return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } }; |
| 165 | } |
| 166 | if (parts.length < 2 || parts[1] === "-") return missing; |
| 167 | const repo = { namespace: parts[0]!, name: parts[1]! }; |
| 168 | // /<owner>/<repo>/issues/<n> and /pull/<n> |
| 169 | const number = Number(parts[3]); |
| 170 | if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) { |
| 171 | if (parts[2] === "issues") { |
| 172 | const found = await work.getIssue(repo, number, viewer); |
| 173 | if (!found.ok) return missing; |
| 174 | return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } }; |
| 175 | } |
| 176 | const found = await work.getPull(repo, number, viewer); |
| 177 | if (!found.ok) return missing; |
| 178 | const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null }; |
| 179 | const state = pull.mergedAt ? "merged" : String(pull.status ?? "open"); |
| 180 | return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } }; |
| 181 | } |
| 182 | const found = await repos.get(repo, viewer); |
| 183 | if (!found.ok) return missing; |
| 184 | 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}` } }; |
| 185 | } |
| 186 | |
| 187 | /** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */ |
| 188 | async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> { |
| 189 | const found = await repos.list(viewer, { namespace: slug }); |
| 190 | return { |
| 191 | ok: true, |
| 192 | value: found |
| 193 | .filter((r) => !r.forkOf) |
| 194 | .slice(0, 300) |
| 195 | .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })), |
| 196 | }; |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * A citation for `path` in `repo`, as the viewer can read it: pinned to |
| 201 | * the default branch's head commit, once the file or folder is there (a |
| 202 | * glob is taken as written). |
| 203 | */ |
| 204 | async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> { |
| 205 | const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/"); |
| 206 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } }; |
| 207 | if (!namespace || !name || rest.length) return missing; |
| 208 | const path = rawPath |
| 209 | .trim() |
| 210 | .replace(/\\/g, "/") |
| 211 | .split("/") |
| 212 | .filter((p) => p && p !== ".") |
| 213 | .join("/"); |
| 214 | if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } }; |
| 215 | const where = { namespace, name }; |
| 216 | const root = await repos.tree(where, viewer, null, ""); |
| 217 | if (!root.ok) return missing; |
| 218 | const head = root.value.head?.hash ?? null; |
| 219 | if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } }; |
| 220 | if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } }; |
| 221 | const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]); |
| 222 | if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } }; |
| 223 | if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } }; |
| 224 | return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } }; |
| 225 | } |
| 226 | |
| 227 | /** Names and faces for member keys, as the workspace knows them. */ |
| 228 | async function who(slug: string, viewer: Parameters<typeof docs.page>[2], keys: string[]): Promise<Result<unknown>> { |
| 229 | const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5)); |
| 230 | const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6))); |
| 231 | 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)]); |
| 232 | const out = keys.map((key) => { |
| 233 | if (key.startsWith("agent:")) { |
| 234 | const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined; |
| 235 | 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 }; |
| 236 | } |
| 237 | const username = names[key.slice(5)]?.toLowerCase(); |
| 238 | const person = username ? people.find((p) => p.name === username) : undefined; |
| 239 | 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 }; |
| 240 | }); |
| 241 | return { ok: true, value: out }; |
| 242 | } |
| 243 | |