| 1 | import { data } from "react-router"; |
| 2 | |
| 3 | import { |
| 4 | folioIdFrom, |
| 5 | isFolioKind, |
| 6 | type DocRole, |
| 7 | type DocSpaceChange, |
| 8 | type Folio, |
| 9 | type FolioAccessChange, |
| 10 | type FolioChange, |
| 11 | type FolioListQuery, |
| 12 | type FolioMove, |
| 13 | type NewDocSpace, |
| 14 | type NewFolio, |
| 15 | type Result, |
| 16 | type User, |
| 17 | } from "@g1t/contracts"; |
| 18 | |
| 19 | import type { Route } from "./+types/api"; |
| 20 | import { workspacePeople } from "../../../lib/chat.server"; |
| 21 | import { listQuery } from "../../../lib/folios"; |
| 22 | import { docs, folios, identity, repos, work } from "../../../lib/services.server"; |
| 23 | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; |
| 24 | |
| 25 | /** |
| 26 | * What Artifacts' pages ask for as they run, as JSON: a page of the home |
| 27 | * list, link search, an artifact's sharing, history and one version, |
| 28 | * templates, cards for embedded g1t things and chat's link cards; and |
| 29 | * everything they change: artifacts, sharing, spaces, members, |
| 30 | * suggestions, templates. Every answer is `{ ok, value }` or |
| 31 | * `{ ok: false, error }`, the services' own shape. |
| 32 | */ |
| 33 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 34 | const viewer = requireUser(context, request); |
| 35 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 36 | const slug = params.owner.toLowerCase(); |
| 37 | const url = new URL(request.url); |
| 38 | const q = url.searchParams; |
| 39 | const answer = await safely<unknown>(async () => { |
| 40 | // A page of the home list ("Show more"): the same filters as the page's address. |
| 41 | if (q.has("list")) return folios.list(slug, viewer, await withOwner(listQuery(q))); |
| 42 | // Words only: what `[[` and the move dialog offer as you type. |
| 43 | if (q.has("search")) return folios.search(slug, viewer, { q: q.get("search") ?? "", mode: "words", limit: Number(q.get("limit")) || 10 }); |
| 44 | if (q.get("access")) return folios.access(slug, viewer, q.get("access")!); |
| 45 | if (q.get("versions")) return folios.versions(slug, viewer, q.get("versions")!); |
| 46 | if (q.get("version") && q.get("folio")) return folios.version(slug, viewer, q.get("folio")!, q.get("version")!); |
| 47 | if (q.has("templates")) return folios.templates(slug, viewer, isFolioKind(q.get("templates")) ? (q.get("templates") as NewFolio["kind"]) : null); |
| 48 | if (q.get("embed")) return embed(slug, q.get("embed")!, viewer, url.origin); |
| 49 | // Chat's card for an artifact link, as this viewer may see it. |
| 50 | if (q.get("unfurl")) return unfurl(slug, q.get("unfurl")!, viewer); |
| 51 | // Repositories to cite or show the docs of: the workspace's that the viewer can read. |
| 52 | if (q.has("repos")) return repositories(slug, viewer); |
| 53 | // A citation's commit: the default branch's head, once the path is there. |
| 54 | if (q.get("cite")) return cite(viewer, q.get("cite")!, q.get("path") ?? ""); |
| 55 | // How member keys show (`user:<id>`, `agent:<id>`): comment authors, people on an artifact. |
| 56 | if (q.get("who")) return who(slug, viewer, q.get("who")!.split(",").slice(0, 100)); |
| 57 | // A member's key (`user:<id>`) by username, for sharing with them or adding them to a space. |
| 58 | if (q.get("person")) { |
| 59 | const user = await identity.userByUsername(q.get("person")!.toLowerCase()); |
| 60 | return user ? { ok: true, value: `user:${user.id}` } : { ok: false, error: { code: "not_found", message: "No such person." } }; |
| 61 | } |
| 62 | // The workspace's teams, to share with. |
| 63 | if (q.has("teams")) { |
| 64 | const teams = await identity.listTeams(viewer, slug).catch(() => null); |
| 65 | return { ok: true, value: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] }; |
| 66 | } |
| 67 | return folios.sidebar(slug, viewer); |
| 68 | }); |
| 69 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 70 | } |
| 71 | |
| 72 | type Sent = { |
| 73 | intent?: string; |
| 74 | folio_id?: string; |
| 75 | space_id?: string; |
| 76 | suggestion_id?: string; |
| 77 | version_id?: string; |
| 78 | template_id?: string; |
| 79 | member?: string; |
| 80 | role?: DocRole | null; |
| 81 | decision?: "accept" | "reject"; |
| 82 | on?: boolean; |
| 83 | folio?: NewFolio; |
| 84 | change?: FolioChange; |
| 85 | move?: FolioMove; |
| 86 | access?: FolioAccessChange; |
| 87 | message?: string | null; |
| 88 | space?: NewDocSpace; |
| 89 | space_change?: DocSpaceChange; |
| 90 | name?: string; |
| 91 | description?: string | null; |
| 92 | repo?: string; |
| 93 | id?: string; |
| 94 | }; |
| 95 | |
| 96 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 97 | assertSameOrigin(request); |
| 98 | const viewer = requireUser(context, request); |
| 99 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 100 | const slug = params.owner.toLowerCase(); |
| 101 | const sent = (await request.json().catch(() => ({}))) as Sent; |
| 102 | const id = sent.folio_id ?? ""; |
| 103 | const answer = await safely(async (): Promise<Result<unknown>> => { |
| 104 | switch (sent.intent) { |
| 105 | case "create": |
| 106 | return folios.create(slug, viewer, sent.folio ?? { kind: "doc" }); |
| 107 | case "update": |
| 108 | return folios.update(slug, viewer, id, sent.change ?? {}); |
| 109 | case "move": |
| 110 | return folios.move(slug, viewer, id, sent.move ?? { space_id: null, parent_id: null }); |
| 111 | case "duplicate": |
| 112 | return folios.duplicate(slug, viewer, id); |
| 113 | case "trash": |
| 114 | return folios.trash(slug, viewer, id); |
| 115 | case "restore": |
| 116 | return folios.restore(slug, viewer, id); |
| 117 | case "delete": |
| 118 | return folios.delete(slug, viewer, id); |
| 119 | case "favorite": |
| 120 | return folios.favorite(slug, viewer, id, !!sent.on); |
| 121 | case "access": |
| 122 | if (!sent.access) return { ok: false, error: { code: "invalid", message: "What should change?" } }; |
| 123 | return folios.changeAccess(slug, viewer, id, sent.access); |
| 124 | case "request_access": |
| 125 | return folios.requestAccess(slug, viewer, id, sent.message ?? null); |
| 126 | case "join_space": |
| 127 | return folios.joinSpace(slug, viewer, sent.space_id ?? ""); |
| 128 | case "leave_space": |
| 129 | return folios.leaveSpace(slug, viewer, sent.space_id ?? ""); |
| 130 | case "restore_version": |
| 131 | return folios.restoreVersion(slug, viewer, id, sent.version_id ?? ""); |
| 132 | case "decide": |
| 133 | return folios.decideSuggestion(slug, viewer, sent.suggestion_id ?? "", sent.decision === "accept" ? "accept" : "reject"); |
| 134 | case "save_template": |
| 135 | return folios.saveTemplate(slug, viewer, { folio_id: id, name: sent.name ?? "", description: sent.description ?? null }); |
| 136 | case "delete_template": |
| 137 | return folios.deleteTemplate(slug, viewer, sent.template_id ?? ""); |
| 138 | case "mark_current": |
| 139 | return folios.markCurrent(slug, viewer, id); |
| 140 | // Spaces are the artifacts service's own, shared with Artifacts. |
| 141 | case "create_space": |
| 142 | return docs.createSpace(slug, viewer, sent.space ?? ({ name: "", kind: "workspace" } as NewDocSpace)); |
| 143 | case "update_space": |
| 144 | return docs.updateSpace(slug, sent.space_id ?? "", viewer, sent.space_change ?? {}); |
| 145 | case "set_member": |
| 146 | return docs.setSpaceMember(slug, sent.space_id ?? "", viewer, sent.member ?? "", sent.role ?? null); |
| 147 | case "add_repo_space": |
| 148 | return docs.addRepoSpace(slug, viewer, sent.repo ?? ""); |
| 149 | case "remove_repo_space": |
| 150 | return docs.removeRepoSpace(slug, viewer, sent.id ?? ""); |
| 151 | default: |
| 152 | return { ok: false, error: { code: "invalid", message: "Unknown request." } }; |
| 153 | } |
| 154 | }); |
| 155 | return Response.json(answer, { headers: { "cache-control": "no-store" } }); |
| 156 | } |
| 157 | |
| 158 | /** The owner filter names a person by username; the service keys people by id. */ |
| 159 | async function withOwner(query: FolioListQuery): Promise<FolioListQuery> { |
| 160 | if (!query.owner) return query; |
| 161 | const user = await identity.userByUsername(query.owner.toLowerCase()).catch(() => null); |
| 162 | return { ...query, owner: user ? `user:${user.id}` : "user:nobody" }; |
| 163 | } |
| 164 | |
| 165 | async function safely<T>(run: () => Promise<Result<T>>): Promise<Result<T>> { |
| 166 | try { |
| 167 | return await run(); |
| 168 | } catch (error) { |
| 169 | console.error("artifacts api:", error); |
| 170 | return { ok: false, error: { code: "conflict", message: "Artifacts didn't answer. Try again in a moment." } }; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Chat's card for an artifact link, for this viewer only (plan section |
| 176 | * 4.3 rule 3): its kind, title, space and last edit when they can read it; |
| 177 | * `null` (no title, nothing else) when they can't or it doesn't exist. |
| 178 | * Looking never records a visit: a card is not opening the link. |
| 179 | */ |
| 180 | async function unfurl(slug: string, address: string, viewer: User): Promise<Result<FolioUnfurl | null>> { |
| 181 | const id = folioIdFrom(address.split(/[?#]/)[0]!.replace(/\/+$/, "")); |
| 182 | if (!id) return { ok: false, error: { code: "invalid", message: "That isn't an artifact's address." } }; |
| 183 | const found = await folios.folio(slug, viewer, id, { peek: true }); |
| 184 | if (!found.ok) return { ok: true, value: null }; |
| 185 | const f = found.value; |
| 186 | return { |
| 187 | ok: true, |
| 188 | value: { id: f.id, kind: f.kind, title: f.title, icon: f.icon, path: f.path, space: f.space?.name ?? null, private: f.private, excerpt: f.excerpt, edited_at: f.edited_at, edited_by: f.edited_by?.display_name ?? null }, |
| 189 | }; |
| 190 | } |
| 191 | |
| 192 | /** What chat's card for an artifact link shows to someone who can read it. */ |
| 193 | export type FolioUnfurl = Pick<Folio, "id" | "kind" | "title" | "icon" | "path" | "private" | "excerpt" | "edited_at"> & { space: string | null; edited_by: string | null }; |
| 194 | |
| 195 | /** |
| 196 | * The card for a g1t address embedded in a doc, as this reader may see |
| 197 | * it: an issue or pull request with its state, a channel, a project, an |
| 198 | * artifact. Anything they can't read is not found, exactly as one that |
| 199 | * does not exist. |
| 200 | */ |
| 201 | async function embed(slug: string, address: string, viewer: User, origin: string): Promise<Result<unknown>> { |
| 202 | let path: string; |
| 203 | try { |
| 204 | const url = new URL(address, origin); |
| 205 | if (url.origin !== origin) return { ok: true, value: { kind: "link", title: url.hostname + url.pathname, subtitle: url.hostname, state: null, href: url.toString() } }; |
| 206 | path = url.pathname; |
| 207 | } catch { |
| 208 | return { ok: false, error: { code: "invalid", message: "That isn't an address." } }; |
| 209 | } |
| 210 | const parts = path.split("/").filter(Boolean).map(decodeURIComponent); |
| 211 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "Not found." } }; |
| 212 | // /<owner>/-/artifacts/<slug>-<id> |
| 213 | if (parts[1] === "-" && parts[2] === "artifacts" && parts[3]) { |
| 214 | if (parts[0]!.toLowerCase() !== slug) return missing; |
| 215 | const id = folioIdFrom(parts[3]); |
| 216 | if (!id) return missing; |
| 217 | const found = await folios.folio(slug, viewer, id, { peek: true }); |
| 218 | if (!found.ok) return missing; |
| 219 | const f = found.value; |
| 220 | return { ok: true, value: { kind: "page", title: `${f.icon ? `${f.icon} ` : ""}${f.title || "Untitled"}`, subtitle: f.space?.name ?? "Private", state: null, href: f.path } }; |
| 221 | } |
| 222 | // /<owner>/-/chat/<channel> |
| 223 | if (parts[1] === "-" && parts[2] === "chat" && parts[3]) { |
| 224 | return { ok: true, value: { kind: "channel", title: parts[3] === "dm" ? "A direct message" : `#${parts[3]}`, subtitle: "Chat", state: null, href: path } }; |
| 225 | } |
| 226 | if (parts.length < 2 || parts[1] === "-") return missing; |
| 227 | const repo = { namespace: parts[0]!, name: parts[1]! }; |
| 228 | // /<owner>/<repo>/issues/<n> and /pull/<n> |
| 229 | const number = Number(parts[3]); |
| 230 | if ((parts[2] === "issues" || parts[2] === "pull" || parts[2] === "pulls") && Number.isInteger(number) && number > 0) { |
| 231 | if (parts[2] === "issues") { |
| 232 | const found = await work.getIssue(repo, number, viewer); |
| 233 | if (!found.ok) return missing; |
| 234 | return { ok: true, value: { kind: "issue", title: `${found.value.issue.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state: found.value.issue.state, href: path } }; |
| 235 | } |
| 236 | const found = await work.getPull(repo, number, viewer); |
| 237 | if (!found.ok) return missing; |
| 238 | const pull = found.value.pull as { title: string; status: string; mergedAt?: string | null }; |
| 239 | const state = pull.mergedAt ? "merged" : String(pull.status ?? "open"); |
| 240 | return { ok: true, value: { kind: "pull", title: `${pull.title} #${number}`, subtitle: `${repo.namespace}/${repo.name}`, state, href: path } }; |
| 241 | } |
| 242 | const found = await repos.get(repo, viewer); |
| 243 | if (!found.ok) return missing; |
| 244 | 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}` } }; |
| 245 | } |
| 246 | |
| 247 | /** The workspace's repositories the viewer can read, newest first: `owner/name` and its default branch. */ |
| 248 | async function repositories(slug: string, viewer: User): Promise<Result<{ repo: string; default_branch: string; private: boolean }[]>> { |
| 249 | const found = await repos.list(viewer, { namespace: slug }); |
| 250 | return { |
| 251 | ok: true, |
| 252 | value: found |
| 253 | .filter((r) => !r.forkOf) |
| 254 | .slice(0, 300) |
| 255 | .map((r) => ({ repo: `${r.namespace}/${r.name}`.toLowerCase(), default_branch: r.defaultBranch, private: r.isPrivate })), |
| 256 | }; |
| 257 | } |
| 258 | |
| 259 | /** |
| 260 | * A citation for `path` in `repo`, as the viewer can read it: pinned to |
| 261 | * the default branch's head commit, once the file or folder is there (a |
| 262 | * glob is taken as written). |
| 263 | */ |
| 264 | async function cite(viewer: User, repo: string, rawPath: string): Promise<Result<{ repo: string; path: string; ref: string | null; kind: "file" | "folder" | "glob" }>> { |
| 265 | const [namespace, name, ...rest] = repo.trim().toLowerCase().split("/"); |
| 266 | const missing = { ok: false as const, error: { code: "not_found" as const, message: "No such repository, or you can't read it." } }; |
| 267 | if (!namespace || !name || rest.length) return missing; |
| 268 | const path = rawPath |
| 269 | .trim() |
| 270 | .replace(/\\/g, "/") |
| 271 | .split("/") |
| 272 | .filter((p) => p && p !== ".") |
| 273 | .join("/"); |
| 274 | if (!path || path.split("/").includes("..")) return { ok: false, error: { code: "invalid", message: "Name a file, a folder or a pattern in the repository." } }; |
| 275 | const where = { namespace, name }; |
| 276 | const root = await repos.tree(where, viewer, null, ""); |
| 277 | if (!root.ok) return missing; |
| 278 | const head = root.value.head?.hash ?? null; |
| 279 | if (/[*?]/.test(path)) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "glob" } }; |
| 280 | if (!head) return { ok: false, error: { code: "not_found", message: "That repository has no commits yet." } }; |
| 281 | const [blob, tree] = await Promise.all([repos.blob(where, viewer, head, path).catch(() => null), repos.tree(where, viewer, head, path).catch(() => null)]); |
| 282 | if (blob?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "file" } }; |
| 283 | if (tree?.ok) return { ok: true, value: { repo: `${namespace}/${name}`, path, ref: head, kind: "folder" } }; |
| 284 | return { ok: false, error: { code: "not_found", message: `There is no ${path} on ${root.value.repo.defaultBranch}.` } }; |
| 285 | } |
| 286 | |
| 287 | /** Names and faces for member keys, as the workspace knows them. */ |
| 288 | async function who(slug: string, viewer: User, keys: string[]): Promise<Result<unknown>> { |
| 289 | const userIds = keys.filter((k) => k.startsWith("user:")).map((k) => k.slice(5)); |
| 290 | const agentIds = new Set(keys.filter((k) => k.startsWith("agent:")).map((k) => k.slice(6))); |
| 291 | 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)]); |
| 292 | const out = keys.map((key) => { |
| 293 | if (key.startsWith("agent:")) { |
| 294 | const agent = agentIds.has(key.slice(6)) ? agents.find((a) => a.id === key.slice(6)) : undefined; |
| 295 | 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, look: agent?.look ?? null }; |
| 296 | } |
| 297 | const username = names[key.slice(5)]?.toLowerCase(); |
| 298 | const person = username ? people.find((p) => p.name === username) : undefined; |
| 299 | 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 }; |
| 300 | }); |
| 301 | return { ok: true, value: out }; |
| 302 | } |