| 1 | /** |
| 2 | * The Marketplace (routes/workspace/marketplace/): what adds functionality |
| 3 | * to a workspace, read against what it has and what its people asked for. |
| 4 | * |
| 5 | * Two kinds of listing: extensions, which add pages, data, cards and agent |
| 6 | * roles (g1t's own are listed before their first release), and |
| 7 | * integrations, which connect what a team already uses so agents can work |
| 8 | * with it. Owners add them; anyone else asks, and the agents service keeps |
| 9 | * the request (`install_requests`). Agents aren't listed: they start from |
| 10 | * templates in Agents mode (lib/agent-templates.ts). |
| 11 | * |
| 12 | * No Workers or React imports, so it can be tested under Node. |
| 13 | */ |
| 14 | import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingKind, ListingTier } from "@g1t/contracts"; |
| 15 | import { CONNECTOR_CATEGORIES, type Connector, type ConnectorCapability, type ConnectorScope, type ConnectorView, connectorPath, connectorView } from "@g1t/contracts/connectors"; |
| 16 | import { CONNECTOR_PUBLISHER, LISTING_TIERS, listingRef, parseListing } from "@g1t/contracts/marketplace"; |
| 17 | |
| 18 | import type { ConnectedState } from "./connectors"; |
| 19 | |
| 20 | /** The Marketplace's pages, under `/<workspace>/-/marketplace`. */ |
| 21 | export function marketplacePath(slug: string, page: "" | "extensions" | "integrations" | "requests" = ""): string { |
| 22 | return `/${slug}/-/marketplace${page ? `/${page}` : ""}`; |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * Who stands behind a listing: in a word, a sentence, and what a tier's |
| 27 | * section says while nothing in it is listed (`none`, by kind). |
| 28 | */ |
| 29 | export const TIERS: Record<ListingTier, { label: string; about: string; none: Record<ListingKind, string> }> = { |
| 30 | official: { |
| 31 | label: "Official", |
| 32 | about: "Built and supported by g1t.", |
| 33 | none: { extension: "No official extensions match.", integration: "No official integrations match." }, |
| 34 | }, |
| 35 | verified: { |
| 36 | label: "Verified", |
| 37 | about: "From a reviewed publisher. Its code and the scopes it asks for are checked before it is listed.", |
| 38 | none: { extension: "No verified publishers yet.", integration: "No verified publishers yet." }, |
| 39 | }, |
| 40 | community: { |
| 41 | label: "Community", |
| 42 | about: "From anyone. Its pages run sandboxed on the user-content domain, and its server code stays off until that sandbox is hardened.", |
| 43 | none: { extension: "No community extensions yet.", integration: "No community integrations yet." }, |
| 44 | }, |
| 45 | internal: { |
| 46 | label: "Internal", |
| 47 | about: "Built by your own people and agents, and promoted for this workspace only.", |
| 48 | none: { extension: "Nothing built in this workspace yet.", integration: "Nothing built in this workspace yet." }, |
| 49 | }, |
| 50 | }; |
| 51 | |
| 52 | /** |
| 53 | * Whether a listing can be added here, now: it can (`available`), the |
| 54 | * workspace has it (`added`: connected, or installed), it is planned and |
| 55 | * can't be added by anyone yet (`soon`), or this workspace or this g1t |
| 56 | * lacks something it needs (`unavailable`, with why). |
| 57 | */ |
| 58 | export type Availability = "available" | "added" | "soon" | "unavailable"; |
| 59 | |
| 60 | export const AVAILABILITIES: readonly Availability[] = ["available", "added", "soon", "unavailable"]; |
| 61 | |
| 62 | /** Each availability in words, and the sentence its hint shows. */ |
| 63 | export const AVAILABILITY: Record<Availability, { label: string; about: string }> = { |
| 64 | available: { label: "Available", about: "Can be added now. Owners add it for everyone; anyone else can ask an owner." }, |
| 65 | added: { label: "Added", about: "This workspace has it." }, |
| 66 | soon: { label: "Soon", about: "Planned, not built yet. Nobody can add it until it is released." }, |
| 67 | unavailable: { label: "Not available here", about: "This workspace or this g1t lacks something it needs." }, |
| 68 | }; |
| 69 | |
| 70 | /** What a workspace that has a listing calls it: an integration is connected, an extension installed. */ |
| 71 | export function addedWord(kind: ListingKind): string { |
| 72 | return kind === "integration" ? "Connected" : "Installed"; |
| 73 | } |
| 74 | |
| 75 | /** A filter's value: one tier or availability, or every one. */ |
| 76 | export type ListingFilters = { tier: ListingTier | "all"; availability: Availability | "all" }; |
| 77 | |
| 78 | /** The filters a Marketplace page's address asks for (`?tier=verified&availability=soon`); anything unknown is All. */ |
| 79 | export function readFilters(params: URLSearchParams): ListingFilters { |
| 80 | const tier = params.get("tier"); |
| 81 | const availability = params.get("availability"); |
| 82 | return { |
| 83 | tier: LISTING_TIERS.includes(tier as ListingTier) ? (tier as ListingTier) : "all", |
| 84 | availability: AVAILABILITIES.includes(availability as Availability) ? (availability as Availability) : "all", |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | /** Whether a listing passes the filters. */ |
| 89 | export function passes(listing: { tier: ListingTier; availability: Availability }, filters: ListingFilters): boolean { |
| 90 | return (filters.tier === "all" || listing.tier === filters.tier) && (filters.availability === "all" || listing.availability === filters.availability); |
| 91 | } |
| 92 | |
| 93 | /** The tiers a page shows sections for under `filters`: every one, or the one asked for. */ |
| 94 | export function tiersShown(filters: ListingFilters): readonly ListingTier[] { |
| 95 | return filters.tier === "all" ? LISTING_TIERS : [filters.tier]; |
| 96 | } |
| 97 | |
| 98 | /** Whether `request` is `username`'s and still waiting. */ |
| 99 | function openFor(request: InstallRequest, ref: string, username: string): boolean { |
| 100 | return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase(); |
| 101 | } |
| 102 | |
| 103 | /** Open requests for `ref`. */ |
| 104 | function openCount(requests: InstallRequest[], ref: string): number { |
| 105 | return requests.filter((request) => request.status === "open" && request.listing === ref).length; |
| 106 | } |
| 107 | |
| 108 | /** An integration in the Marketplace, with who stands behind it and whether the workspace has it. */ |
| 109 | export type IntegrationListing = { |
| 110 | ref: string; |
| 111 | view: ConnectorView; |
| 112 | /** Who connects it: an owner, once for the workspace, or each person for themselves. */ |
| 113 | scope: ConnectorScope; |
| 114 | /** Every connector is g1t's own (CONNECTOR_PUBLISHER). */ |
| 115 | tier: ListingTier; |
| 116 | publisher: string; |
| 117 | availability: Availability; |
| 118 | /** Why it can't be connected here, when it can't. */ |
| 119 | why: string | null; |
| 120 | /** How it is doing, when it is connected. */ |
| 121 | connected: ConnectedState | null; |
| 122 | /** Where an owner connects it, or manages it once connected. */ |
| 123 | href: string | null; |
| 124 | /** Its page in the Marketplace. */ |
| 125 | path: string; |
| 126 | requested: boolean; |
| 127 | waiting: number; |
| 128 | }; |
| 129 | |
| 130 | /** One connector as a listing in the workspace `slug`. */ |
| 131 | function integrationListing( |
| 132 | view: ConnectorView, |
| 133 | scope: ConnectorScope, |
| 134 | slug: string, |
| 135 | connected: Record<string, ConnectedState> = {}, |
| 136 | unavailable: Record<string, string> = {}, |
| 137 | requests: InstallRequest[] = [], |
| 138 | username = "", |
| 139 | ): IntegrationListing { |
| 140 | const ref = listingRef("integration", view.id); |
| 141 | const state = scope === "workspace" ? (connected[view.id] ?? null) : null; |
| 142 | const why = view.status === "available" && !state ? (unavailable[view.id] ?? null) : null; |
| 143 | const availability: Availability = state ? "added" : view.status !== "available" ? "soon" : why ? "unavailable" : "available"; |
| 144 | const setup = view.href && !why ? connectorPath(view.href, slug) : null; |
| 145 | return { |
| 146 | ref, |
| 147 | view, |
| 148 | scope, |
| 149 | tier: CONNECTOR_PUBLISHER.tier, |
| 150 | publisher: CONNECTOR_PUBLISHER.name, |
| 151 | availability, |
| 152 | why, |
| 153 | connected: state, |
| 154 | href: state?.manage ?? setup, |
| 155 | path: integrationPath(slug, view.id), |
| 156 | requested: requests.some((request) => openFor(request, ref, username)), |
| 157 | waiting: openCount(requests, ref), |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * The integrations a workspace can connect today, connected ones first, |
| 163 | * each in catalog order. `connected` comes from the integrations service |
| 164 | * and the GitHub App (lib/connected.server.ts), as does `unavailable`: |
| 165 | * connectors this g1t can't connect, with why. |
| 166 | */ |
| 167 | export function integrationListings( |
| 168 | views: ConnectorView[], |
| 169 | connected: Record<string, ConnectedState>, |
| 170 | requests: InstallRequest[], |
| 171 | username: string, |
| 172 | slug: string, |
| 173 | unavailable: Record<string, string> = {}, |
| 174 | ): IntegrationListing[] { |
| 175 | const listings = views.filter((view) => view.status === "available").map((view) => integrationListing(view, "workspace", slug, connected, unavailable, requests, username)); |
| 176 | return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)]; |
| 177 | } |
| 178 | |
| 179 | /** What each person connects for themselves, available today, as listings. */ |
| 180 | export function personalListings(views: ConnectorView[], slug: string, unavailable: Record<string, string> = {}): IntegrationListing[] { |
| 181 | return views.filter((view) => view.status === "available" && view.href != null).map((view) => integrationListing(view, "personal", slug, {}, unavailable)); |
| 182 | } |
| 183 | |
| 184 | /** The coming integrations (comingIntegrations), as listings: Soon, with nothing to press. */ |
| 185 | export function comingListings(views: ConnectorView[], personal: ConnectorView[], slug: string): IntegrationListing[] { |
| 186 | const workspace = new Set(views.map((view) => view.id)); |
| 187 | return comingIntegrations(views, personal).map((view) => integrationListing(view, workspace.has(view.id) ? "workspace" : "personal", slug)); |
| 188 | } |
| 189 | |
| 190 | /** One integration's page in the Marketplace. */ |
| 191 | export function integrationPath(slug: string, id: string): string { |
| 192 | return `/${slug}/-/marketplace/integrations/${id}`; |
| 193 | } |
| 194 | |
| 195 | /** One way an integration is connected, and what it does there. */ |
| 196 | export type IntegrationUse = { |
| 197 | scope: ConnectorScope; |
| 198 | /** Who connects it this way, in words. */ |
| 199 | who: string; |
| 200 | description: string; |
| 201 | capabilities: ConnectorCapability[]; |
| 202 | }; |
| 203 | |
| 204 | /** |
| 205 | * What an integration does, today and soon: each way it is connected (for |
| 206 | * the workspace, for each person) under whether that way is available yet, |
| 207 | * from the catalog. Linear, say, works for a workspace today, and for each |
| 208 | * person's own inbox soon. |
| 209 | */ |
| 210 | export function integrationUses(connector: Connector): { today: IntegrationUse[]; soon: IntegrationUse[] } { |
| 211 | const today: IntegrationUse[] = []; |
| 212 | const soon: IntegrationUse[] = []; |
| 213 | for (const scope of connector.scopes) { |
| 214 | const view = connectorView(connector, scope); |
| 215 | if (!view) continue; |
| 216 | const use = { |
| 217 | scope, |
| 218 | who: scope === "workspace" ? "For the whole workspace, connected once by an owner" : "For each person, with their own account", |
| 219 | description: view.description, |
| 220 | capabilities: view.capabilities, |
| 221 | }; |
| 222 | (view.status === "available" ? today : soon).push(use); |
| 223 | } |
| 224 | return { today, soon }; |
| 225 | } |
| 226 | |
| 227 | /** A connector category's title: `issues` is `Issues & projects`. */ |
| 228 | export function categoryTitle(category: Connector["category"]): string { |
| 229 | return CONNECTOR_CATEGORIES.find((c) => c.id === category)?.title ?? category; |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * The integrations the catalog lists as coming: a workspace's, then those |
| 234 | * each person connects for themselves (a calendar, a mailbox), once each. |
| 235 | */ |
| 236 | export function comingIntegrations(views: ConnectorView[], personal: ConnectorView[] = []): ConnectorView[] { |
| 237 | const workspace = views.filter((view) => view.status === "soon"); |
| 238 | const seen = new Set(workspace.map((view) => view.id)); |
| 239 | const own = personal.filter((view) => view.status === "soon" && !seen.has(view.id) && !views.some((v) => v.id === view.id)); |
| 240 | return [...workspace, ...own]; |
| 241 | } |
| 242 | |
| 243 | /** An extension in the Marketplace, with who stands behind it and whether the workspace has it. */ |
| 244 | export type ExtensionListing = { |
| 245 | ref: string; |
| 246 | manifest: ExtensionManifest; |
| 247 | /** Its publisher's tier. */ |
| 248 | tier: ListingTier; |
| 249 | availability: Availability; |
| 250 | install: ExtensionInstall | null; |
| 251 | requested: boolean; |
| 252 | waiting: number; |
| 253 | }; |
| 254 | |
| 255 | /** Where extensions are grouped: connected systems apart from the rest. */ |
| 256 | export const CONNECTED_SYSTEMS = "Connected systems"; |
| 257 | |
| 258 | /** Whether an extension bridges a system the team already runs. */ |
| 259 | export function isConnectedSystem(manifest: Pick<ExtensionManifest, "category">): boolean { |
| 260 | return manifest.category === CONNECTED_SYSTEMS; |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * A starting set of extensions for one kind of team, installed together |
| 265 | * once each is published. Ids are first-party extensions'. |
| 266 | */ |
| 267 | export type StarterKit = { id: string; name: string; about: string; extensions: string[] }; |
| 268 | |
| 269 | export const STARTER_KITS: StarterKit[] = [ |
| 270 | { id: "customers", name: "Customer team", about: "Mail on your domain, support conversations and a pipeline.", extensions: ["mail", "support", "crm"] }, |
| 271 | { id: "engineering", name: "Engineering extras", about: "On-call rotations, and the helpdesk your customers already write to.", extensions: ["on-call", "helpdesk-bridge"] }, |
| 272 | { id: "operations", name: "People and operations", about: "Hiring, and the orders and invoices in your ERP.", extensions: ["recruiting", "erp-bridge"] }, |
| 273 | ]; |
| 274 | |
| 275 | /** Every extension listed, published ones first, each with its install. */ |
| 276 | export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] { |
| 277 | const listings = manifests.map((manifest) => { |
| 278 | const ref = listingRef("extension", manifest.id); |
| 279 | const install = installs.find((install) => install.listing === ref) ?? null; |
| 280 | return { |
| 281 | ref, |
| 282 | manifest, |
| 283 | tier: manifest.publisher.tier, |
| 284 | availability: (install ? "added" : manifest.status === "available" ? "available" : "soon") as Availability, |
| 285 | install, |
| 286 | requested: requests.some((request) => openFor(request, ref, username)), |
| 287 | waiting: openCount(requests, ref), |
| 288 | }; |
| 289 | }); |
| 290 | return [...listings.filter((l) => l.manifest.status === "available"), ...listings.filter((l) => l.manifest.status !== "available")]; |
| 291 | } |
| 292 | |
| 293 | /** One extension's page in the Marketplace. */ |
| 294 | export function extensionPath(slug: string, id: string): string { |
| 295 | return `/${slug}/-/marketplace/extensions/${id}`; |
| 296 | } |
| 297 | |
| 298 | /** Where an extension runs, in words. */ |
| 299 | export function runtimeWords(manifest: Pick<ExtensionManifest, "runtime" | "publisher">): string { |
| 300 | return manifest.runtime === "hosted" ? `Runs on g1t` : `Runs on ${manifest.publisher.name}'s servers`; |
| 301 | } |
| 302 | |
| 303 | /** Whether an integration listing matches what someone typed: its name, category words, capabilities or keywords. */ |
| 304 | export function integrationMatches(listing: IntegrationListing, query: string): boolean { |
| 305 | const wanted = query.trim().toLowerCase(); |
| 306 | if (!wanted) return true; |
| 307 | const { view } = listing; |
| 308 | return [view.name, view.description, view.category, ...view.capabilities, ...view.keywords].some((word) => word.toLowerCase().includes(wanted)); |
| 309 | } |
| 310 | |
| 311 | /** The requests still waiting on an owner. */ |
| 312 | export function openRequests(requests: InstallRequest[]): InstallRequest[] { |
| 313 | return requests.filter((request) => request.status === "open"); |
| 314 | } |
| 315 | |
| 316 | /** Where an owner goes to add what a request asks for: the extension's page, or the integration's setup page. */ |
| 317 | export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null { |
| 318 | const parsed = parseListing(request.listing); |
| 319 | if (!parsed) return null; |
| 320 | if (parsed.kind === "extension") return extensionPath(slug, parsed.id); |
| 321 | const view = views.find((v) => v.id === parsed.id); |
| 322 | return view?.href ? connectorPath(view.href, slug) : null; |
| 323 | } |
| 324 | |
| 325 | /** What the Marketplace's forms ask for, read from one. */ |
| 326 | export type MarketplaceForm = |
| 327 | | { intent: "request"; listing: string; note: string | null } |
| 328 | | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> } |
| 329 | | { intent: "install"; extension: string } |
| 330 | | { intent: "switch"; listing: string; enabled: boolean } |
| 331 | | { intent: "uninstall"; listing: string }; |
| 332 | |
| 333 | /** A Marketplace form's request; null when it asks for nothing that makes sense. */ |
| 334 | export function marketplaceForm(form: FormData): MarketplaceForm | null { |
| 335 | const intent = form.get("intent"); |
| 336 | if (intent === "request") { |
| 337 | const listing = String(form.get("listing") ?? ""); |
| 338 | if (!parseListing(listing)) return null; |
| 339 | const note = String(form.get("note") ?? "").trim(); |
| 340 | return { intent, listing, note: note || null }; |
| 341 | } |
| 342 | if (intent === "resolve") { |
| 343 | const id = String(form.get("id") ?? ""); |
| 344 | const status = form.get("status"); |
| 345 | if (!id || (status !== "done" && status !== "declined")) return null; |
| 346 | return { intent, id, status }; |
| 347 | } |
| 348 | const listing = String(form.get("listing") ?? ""); |
| 349 | const extension = parseListing(listing)?.kind === "extension" ? listing : null; |
| 350 | if (intent === "install") { |
| 351 | const id = String(form.get("extension") ?? ""); |
| 352 | return /^[a-z][a-z0-9-]{1,31}$/.test(id) ? { intent, extension: id } : null; |
| 353 | } |
| 354 | if (intent === "switch" && extension) { |
| 355 | const enabled = form.get("enabled"); |
| 356 | return enabled === "on" || enabled === "off" ? { intent, listing: extension, enabled: enabled === "on" } : null; |
| 357 | } |
| 358 | if (intent === "uninstall" && extension) return { intent, listing: extension }; |
| 359 | return null; |
| 360 | } |