| 1 | /** |
| 2 | * Reads Getting started for a workspace's owner: their own state from |
| 3 | * identity (`onboarding`), and each step's facts from the services that |
| 4 | * keep them, all at once. A service that does not answer leaves its fact |
| 5 | * null, and its step not done, rather than failing the page. Never awaited |
| 6 | * by a page: Home and the Workspace page stream it, and the dialog opens |
| 7 | * when it arrives (lib/onboarding.ts says when). |
| 8 | */ |
| 9 | import type { AiConnection, AskSetup, Onboarding, OnboardingChange, OnboardingStep, User, Viewer } from "@g1t/contracts"; |
| 10 | |
| 11 | import { githubApp } from "./github.server"; |
| 12 | import { invitationFilterOf } from "./invites"; |
| 13 | import { type OnboardingFacts, type OnboardingView, stepsFrom, withSkipped } from "./onboarding"; |
| 14 | import { runners } from "./runners.server"; |
| 15 | import { ai, billing, identity } from "./services.server"; |
| 16 | import { roleIn } from "./session.server"; |
| 17 | import { workspaceProjects } from "./workspace-projects.server"; |
| 18 | |
| 19 | /** Whether `viewer` is a person who owns `slug`: the only ones the guide is for. */ |
| 20 | export function guideIsFor(viewer: Viewer, slug: string): viewer is User { |
| 21 | return viewer != null && (viewer.kind ?? "user") === "user" && roleIn(viewer, slug) === "owner"; |
| 22 | } |
| 23 | |
| 24 | /** The g1t CLI names the token it signs in with `g1t CLI on <machine>` (crates/g1t, `g1t auth login`). */ |
| 25 | const CLI_TOKEN = /^g1t CLI\b/; |
| 26 | |
| 27 | const settle = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); |
| 28 | |
| 29 | /** The facts each step is judged by, and the providers and Ask's setup the AI steps show. */ |
| 30 | async function factsFor(user: User, slug: string): Promise<{ facts: OnboardingFacts; connections: AiConnection[] | null; ask: AskSetup | null }> { |
| 31 | const [providers, ask, projects, github, tokens, apps, members, invites, free, own] = await Promise.all([ |
| 32 | settle(ai.list(slug, user)), |
| 33 | settle(ai.askSettings(slug, user)), |
| 34 | settle(workspaceProjects(slug, user)), |
| 35 | settle(githubApp.status(user, slug)), |
| 36 | settle(identity.listAccessTokens(user)), |
| 37 | settle(identity.listOAuthGrants(user)), |
| 38 | settle(identity.listMembers(slug, user)), |
| 39 | settle(identity.workspaceInvites(slug, user)), |
| 40 | settle(billing.freeWorkspaces([slug])), |
| 41 | settle(runners.list(user, { workspace: slug })), |
| 42 | ]); |
| 43 | const connections = providers?.ok ? providers.value : null; |
| 44 | const setup = ask?.ok ? ask.value : null; |
| 45 | const facts: OnboardingFacts = { |
| 46 | providers: connections ? { total: connections.length, working: connections.filter((c) => !c.lastError).length } : null, |
| 47 | askReady: setup ? Boolean(setup.provider_id && !setup.problem) : null, |
| 48 | askWith: setup?.provider_id ? [setup.provider_name, setup.auto ? "Auto" : setup.model].filter(Boolean).join(" · ") : null, |
| 49 | projects: projects?.ok ? projects.value.length : null, |
| 50 | github: github?.ok ? { configured: github.value.configured, connected: github.value.installations.some((i) => !i.suspended) } : null, |
| 51 | cli: tokens ? tokens.some((token) => CLI_TOKEN.test(token.name)) : null, |
| 52 | harnessApp: apps ? apps.length > 0 : null, |
| 53 | people: members?.ok |
| 54 | ? { |
| 55 | others: members.value.filter((member) => member.username !== user.username).length, |
| 56 | pending: invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0, |
| 57 | } |
| 58 | : null, |
| 59 | invitesNeedPlan: free ? free.includes(slug) : false, |
| 60 | runners: own?.ok ? own.value.length : null, |
| 61 | }; |
| 62 | return { facts, connections, ask: setup }; |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Getting started for `viewer` in `slug`, or null when it is not for them |
| 67 | * or identity did not answer. `opening`: for a page where it only opens by |
| 68 | * itself (Home, the Workspace page), so once the person has dismissed it |
| 69 | * the facts are not read at all and this is null. |
| 70 | */ |
| 71 | export async function loadOnboarding(viewer: Viewer, slug: string, opening = false): Promise<OnboardingView | null> { |
| 72 | if (!guideIsFor(viewer, slug)) return null; |
| 73 | const stateP = settle(identity.onboarding(viewer, slug)); |
| 74 | if (opening) { |
| 75 | const state = await stateP; |
| 76 | if (!state?.ok || state.value.dismissed_at != null) return null; |
| 77 | } |
| 78 | const [state, read] = await Promise.all([stateP, factsFor(viewer, slug)]); |
| 79 | if (!state?.ok) return null; |
| 80 | const name = viewer.workspaces?.find((membership) => membership.slug === slug)?.name?.trim() || slug; |
| 81 | const { facts, connections, ask } = read; |
| 82 | return { slug, name, state: state.value, facts, connections, ask, steps: stepsFrom(facts, state.value) }; |
| 83 | } |
| 84 | |
| 85 | /** Saves a change to `user`'s own Getting started in `slug`: as kept, or the reason it was refused. */ |
| 86 | export async function saveOnboarding(user: User, slug: string, change: OnboardingChange): Promise<{ state: Onboarding } | { error: string }> { |
| 87 | try { |
| 88 | const saved = await identity.setOnboarding(user, slug, change); |
| 89 | return saved.ok ? { state: saved.value } : { error: saved.error.message }; |
| 90 | } catch (error) { |
| 91 | console.error("onboarding: could not be saved", error); |
| 92 | return { error: "Getting started couldn't be saved. Try again." }; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /** Skips `step`, or takes it back, keeping the other steps as the person left them. */ |
| 97 | export async function skipStep(user: User, slug: string, step: OnboardingStep, skip: boolean): Promise<{ state: Onboarding } | { error: string }> { |
| 98 | const current = await settle(identity.onboarding(user, slug)); |
| 99 | if (!current?.ok) return { error: current ? current.error.message : "Getting started couldn't be read. Try again." }; |
| 100 | return saveOnboarding(user, slug, { skipped: withSkipped(current.value.skipped, step, skip) }); |
| 101 | } |