Skip to content
199 linesCodeBlameRaw
1/**
2 * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a
3 * member asks the workspace's owners to add an agent from the catalog or
4 * an integration, and an owner adds it or turns it down. Kept in
5 * `install_requests`, one open request per person and listing.
6 *
7 * Only what can be added today can be asked for: a role from the agent
8 * catalog (./templates.ts), or a connector the catalog marks available for
9 * a workspace (@g1t/contracts/connectors). Who is told, and how, is
10 * index.ts's; this file is the rules and the table, tested on SQLite.
11 */
12import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts";
13import { CONNECTORS } from "@g1t/contracts/connectors";
14import { ANSWERED_REQUESTS_DAYS, MAX_OPEN_REQUESTS, extensionById, listingRef, parseListing } from "@g1t/contracts/marketplace";
15
16// Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests.
17const ok = <T>(value: T): Result<T> => ({ ok: true, value });
18const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
19
20import { TEMPLATES } from "./templates.ts";
21
22/** Something the workspace can add, as a request names it. */
23export type Listing = { ref: string; kind: ListingKind; id: string; name: string };
24
25/** The listing `value` names, when it is one that can be added today; null otherwise. */
26export function findListing(value: unknown): Listing | null {
27 const parsed = parseListing(value);
28 if (!parsed) return null;
29 if (parsed.kind === "agent") {
30 const template = TEMPLATES.find((t) => t.id === parsed.id);
31 return template ? { ref: listingRef("agent", template.id), kind: "agent", id: template.id, name: template.title } : null;
32 }
33 if (parsed.kind === "extension") {
34 const extension = extensionById(parsed.id);
35 return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null;
36 }
37 const connector = CONNECTORS.find((c) => c.id === parsed.id);
38 if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null;
39 return { ref: listingRef("integration", connector.id), kind: "integration", id: connector.id, name: connector.name };
40}
41
42type Row = {
43 id: string;
44 listing: string;
45 name: string;
46 note: string | null;
47 requested_by: string;
48 requested_by_id: string;
49 requested_at: string;
50 status: string;
51 resolved_by: string | null;
52 resolved_at: string | null;
53};
54
55const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at";
56
57export function toRequest(row: Row): InstallRequest {
58 return {
59 id: row.id,
60 listing: row.listing,
61 kind: parseListing(row.listing)?.kind ?? "agent",
62 name: row.name,
63 note: row.note,
64 requested_by: row.requested_by,
65 requested_at: row.requested_at,
66 status: (["open", "done", "declined"].includes(row.status) ? row.status : "open") as InstallRequestStatus,
67 resolved_by: row.resolved_by,
68 resolved_at: row.resolved_at,
69 };
70}
71
72/** Who is asking or answering. */
73export type Person = { id: string; username: string };
74
75/**
76 * Requests as `viewer` sees them: every one in the workspace for an owner,
77 * their own for anyone else. Open ones first, then the latest; answered
78 * ones for `ANSWERED_REQUESTS_DAYS`. At most 200.
79 */
80export async function listRequests(db: D1Database, workspaceId: string, viewer: Person, owner: boolean, now = new Date()): Promise<InstallRequest[]> {
81 const since = new Date(now.getTime() - ANSWERED_REQUESTS_DAYS * 86_400_000).toISOString();
82 const mine = owner ? "" : " AND requested_by_id = ?3";
83 const statement = db.prepare(
84 `SELECT ${COLUMNS} FROM install_requests
85 WHERE workspace_id = ?1 AND (status = 'open' OR resolved_at >= ?2)${mine}
86 ORDER BY status = 'open' DESC, requested_at DESC LIMIT 200`,
87 );
88 const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id);
89 const { results } = await bound.all<Row>();
90 return results.map(toRequest);
91}
92
93/**
94 * Opens a request for `listing`, as `id`. A conflict when the person has
95 * one open for it already; a limit when they have `MAX_OPEN_REQUESTS` open.
96 */
97export async function openRequest(
98 db: D1Database,
99 workspaceId: string,
100 id: string,
101 listing: Listing,
102 by: Person,
103 note: string | null,
104 now = new Date(),
105): Promise<Result<InstallRequest>> {
106 const open = await db
107 .prepare("SELECT COUNT(*) AS n FROM install_requests WHERE workspace_id = ? AND requested_by_id = ? AND status = 'open'")
108 .bind(workspaceId, by.id)
109 .first<{ n: number }>();
110 if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS) {
111 return fail("limit", `You have ${MAX_OPEN_REQUESTS} requests waiting on an owner. Ask again once some are answered.`);
112 }
113 const row: Row = {
114 id,
115 listing: listing.ref,
116 name: listing.name,
117 note,
118 requested_by: by.username,
119 requested_by_id: by.id,
120 requested_at: now.toISOString(),
121 status: "open",
122 resolved_by: null,
123 resolved_at: null,
124 };
125 // The unique index on open requests turns a second press into nothing.
126 const inserted = await db
127 .prepare(
128 `INSERT INTO install_requests (id, workspace_id, listing, name, note, requested_by, requested_by_id, requested_at, status)
129 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') ON CONFLICT DO NOTHING RETURNING id`,
130 )
131 .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at)
132 .first<{ id: string }>();
133 if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`);
134 return ok(toRequest(row));
135}
136
137/** A request answered, with who asked, for telling them. */
138export type Answered = { request: InstallRequest; requested_by_id: string };
139
140/** An owner adds (`done`) or turns down (`declined`) an open request. */
141export async function resolveRequest(
142 db: D1Database,
143 workspaceId: string,
144 id: string,
145 status: Exclude<InstallRequestStatus, "open">,
146 by: Person,
147 now = new Date(),
148): Promise<Result<Answered>> {
149 if (status !== "done" && status !== "declined") return fail("invalid", "Answer a request with done or declined.");
150 const row = await db
151 .prepare(
152 `UPDATE install_requests SET status = ?, resolved_by = ?, resolved_at = ?
153 WHERE id = ? AND workspace_id = ? AND status = 'open' RETURNING ${COLUMNS}`,
154 )
155 .bind(status, by.username, now.toISOString(), id, workspaceId)
156 .first<Row>();
157 if (row) return ok({ request: toRequest(row), requested_by_id: row.requested_by_id });
158 const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>();
159 return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request.");
160}
161
162/**
163 * Every open request for `listing`, marked added: an owner added it, so
164 * whoever asked for it has what they asked for.
165 */
166export async function resolveListing(db: D1Database, workspaceId: string, listing: string, by: Person, now = new Date()): Promise<Answered[]> {
167 const { results } = await db
168 .prepare(
169 `UPDATE install_requests SET status = 'done', resolved_by = ?, resolved_at = ?
170 WHERE workspace_id = ? AND listing = ? AND status = 'open' RETURNING ${COLUMNS}`,
171 )
172 .bind(by.username, now.toISOString(), workspaceId, listing)
173 .all<Row>();
174 return results.map((row) => ({ request: toRequest(row), requested_by_id: row.requested_by_id }));
175}
176
177/** Where a request is looked at in the workspace `slug`. */
178export function requestsPath(slug: string): string {
179 return `/${slug}/-/marketplace/requests`;
180}
181
182/** Where the thing a request asked for is, once added. */
183export function listingPath(slug: string, listing: string): string {
184 const parsed = parseListing(listing);
185 if (parsed?.kind === "agent") return `/${slug}/-/marketplace/agents/${parsed.id}`;
186 if (parsed?.kind === "extension") return `/${slug}/-/marketplace/extensions/${parsed.id}`;
187 return `/${slug}/-/marketplace/integrations`;
188}
189
190/** What the person who asked is told when their request is answered. */
191export function answerLine(request: InstallRequest, by: string): { title: string; body: string } {
192 if (request.status === "declined") {
193 return { title: `${by} turned down your request for ${request.name}`, body: "Ask them in chat if you want to know why." };
194 }
195 return {
196 title: `${request.name} was added to the workspace`,
197 body: request.kind === "agent" ? `${by} added it from the agent catalog.` : request.kind === "extension" ? `${by} installed it.` : `${by} connected it.`,
198 };
199}