Skip to content
302 linesCodeBlameRaw
1import { data } from "react-router";
2
3import {
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
19import type { Route } from "./+types/api";
20import { workspacePeople } from "../../../lib/chat.server";
21import { listQuery } from "../../../lib/folios";
22import { docs, folios, identity, repos, work } from "../../../lib/services.server";
23import { 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 */
33export 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
72type 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
96export 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 docs 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. */
159async 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
165async 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 */
180async 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. */
193export 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 */
201async 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. */
248async 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 */
264async 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. */
288async 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 };
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}