| author | |
| committer | |
| log | 60af905e70c944228cb35a74fa50740ec4b2d1f7 |
| tree | f96e8d1593cb40912ed287b5481aa100c686361d |
| parent | 8650f05f95ac9f19254de679fdf4b380c37d7b94 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 236 insertions(+), 282 deletions(-)
apps/mobile/src/Stack.tsx+3-3| ... | ... | @@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc |
| 47 | 47 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 48 | 48 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 49 | 49 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 50 | import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; | |
| 51 | 50 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 52 | 51 | import { |
| 53 | 52 | SettingsLegalDocumentCloseHeaderButton, |
| ... | ... | @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 190 | 189 | }, |
| 191 | 190 | }), |
| 192 | 191 | SettingsWaitlist: createNativeStackScreen({ |
| 193 | screen: SettingsWaitlistRouteScreen, | |
| 192 | // Keep the old deep link working after the Connect GA launch. | |
| 193 | screen: SettingsAuthRouteScreen, | |
| 194 | 194 | linking: "waitlist", |
| 195 | 195 | options: { |
| 196 | title: "Join the waitlist", | |
| 196 | title: "Sign in", | |
| 197 | 197 | }, |
| 198 | 198 | }), |
| 199 | 199 | }, |
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx deleted-120| ... | ... | @@ -1,120 +0,0 @@ |
| 1 | import { useWaitlist } from "@clerk/expo"; | |
| 2 | import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native"; | |
| 3 | import { useState } from "react"; | |
| 4 | ||
| 5 | import { cn } from "../../lib/cn"; | |
| 6 | import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; | |
| 7 | ||
| 8 | export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { | |
| 9 | const { errors, fetchStatus, waitlist } = useWaitlist(); | |
| 10 | const [emailAddress, setEmailAddress] = useState(""); | |
| 11 | const [requestError, setRequestError] = useState<string | null>(null); | |
| 12 | const isSubmitting = fetchStatus === "fetching"; | |
| 13 | const fieldError = errors.fields.emailAddress?.longMessage; | |
| 14 | ||
| 15 | const joinWaitlist = async () => { | |
| 16 | const normalizedEmailAddress = emailAddress.trim(); | |
| 17 | if (!normalizedEmailAddress || isSubmitting) { | |
| 18 | return; | |
| 19 | } | |
| 20 | ||
| 21 | setRequestError(null); | |
| 22 | try { | |
| 23 | await joinCloudWaitlist(waitlist, normalizedEmailAddress); | |
| 24 | } catch (error) { | |
| 25 | console.error(error); | |
| 26 | setRequestError( | |
| 27 | error instanceof CloudWaitlistJoinRejectedError | |
| 28 | ? "Could not join the waitlist. Check your email address and try again." | |
| 29 | : "Could not join the waitlist. Check your connection and try again.", | |
| 30 | ); | |
| 31 | } | |
| 32 | }; | |
| 33 | ||
| 34 | if (waitlist.id) { | |
| 35 | return ( | |
| 36 | <View className="gap-[18px]"> | |
| 37 | <Text className="text-center font-t3-bold text-xl text-foreground"> | |
| 38 | You are on the waitlist | |
| 39 | </Text> | |
| 40 | <Text className="text-center font-sans text-base text-foreground-secondary"> | |
| 41 | We will email you when your T3 Connect access is ready. | |
| 42 | </Text> | |
| 43 | <SignInAction onPress={props.onSignIn} /> | |
| 44 | </View> | |
| 45 | ); | |
| 46 | } | |
| 47 | ||
| 48 | return ( | |
| 49 | <View className="gap-[18px]"> | |
| 50 | <Text className="font-sans text-base text-foreground-secondary"> | |
| 51 | Enter your email and we will let you know when access is ready. | |
| 52 | </Text> | |
| 53 | ||
| 54 | <View className="gap-2"> | |
| 55 | <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text> | |
| 56 | <TextInput | |
| 57 | accessibilityLabel="Email address" | |
| 58 | autoCapitalize="none" | |
| 59 | autoComplete="email" | |
| 60 | autoCorrect={false} | |
| 61 | className={cn( | |
| 62 | "min-h-[54px] rounded-2xl border border-input-border bg-input px-4 py-3.5 font-sans text-lg text-foreground border-continuous", | |
| 63 | (fieldError || requestError) && "border-danger-foreground", | |
| 64 | )} | |
| 65 | keyboardType="email-address" | |
| 66 | onChangeText={(value) => { | |
| 67 | setEmailAddress(value); | |
| 68 | setRequestError(null); | |
| 69 | }} | |
| 70 | onSubmitEditing={() => void joinWaitlist()} | |
| 71 | placeholder="Enter your email address" | |
| 72 | placeholderTextColorClassName="accent-placeholder" | |
| 73 | returnKeyType="join" | |
| 74 | textContentType="emailAddress" | |
| 75 | value={emailAddress} | |
| 76 | /> | |
| 77 | {fieldError || requestError ? ( | |
| 78 | <Text | |
| 79 | accessibilityLiveRegion="polite" | |
| 80 | className="font-sans text-sm text-danger-foreground" | |
| 81 | selectable | |
| 82 | > | |
| 83 | {fieldError ?? requestError} | |
| 84 | </Text> | |
| 85 | ) : null} | |
| 86 | </View> | |
| 87 | ||
| 88 | <Pressable | |
| 89 | accessibilityRole="button" | |
| 90 | accessibilityState={{ | |
| 91 | busy: isSubmitting, | |
| 92 | disabled: isSubmitting || emailAddress.trim().length === 0, | |
| 93 | }} | |
| 94 | disabled={isSubmitting || emailAddress.trim().length === 0} | |
| 95 | onPress={() => void joinWaitlist()} | |
| 96 | className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" | |
| 97 | > | |
| 98 | {isSubmitting ? ( | |
| 99 | <ActivityIndicator colorClassName="accent-primary-foreground" size="small" /> | |
| 100 | ) : null} | |
| 101 | <Text className="font-t3-bold text-base text-primary-foreground"> | |
| 102 | {isSubmitting ? "Joining" : "Join the waitlist"} | |
| 103 | </Text> | |
| 104 | </Pressable> | |
| 105 | ||
| 106 | <SignInAction onPress={props.onSignIn} /> | |
| 107 | </View> | |
| 108 | ); | |
| 109 | } | |
| 110 | ||
| 111 | function SignInAction(props: { readonly onPress: () => void }) { | |
| 112 | return ( | |
| 113 | <View className="flex-row items-center justify-center gap-1 pt-1"> | |
| 114 | <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text> | |
| 115 | <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}> | |
| 116 | <Text className="font-t3-bold text-base text-foreground">Sign in</Text> | |
| 117 | </Pressable> | |
| 118 | </View> | |
| 119 | ); | |
| 120 | } |
apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts deleted-48| ... | ... | @@ -1,48 +0,0 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | CloudWaitlistJoinRejectedError, | |
| 5 | CloudWaitlistJoinRequestError, | |
| 6 | joinCloudWaitlist, | |
| 7 | } from "./cloudWaitlistJoin"; | |
| 8 | ||
| 9 | describe("joinCloudWaitlist", () => { | |
| 10 | it("submits the provided email address", async () => { | |
| 11 | const join = vi.fn().mockResolvedValue({ error: null }); | |
| 12 | ||
| 13 | await joinCloudWaitlist({ join }, "person@example.com"); | |
| 14 | ||
| 15 | expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); | |
| 16 | }); | |
| 17 | ||
| 18 | it("preserves Clerk rejection details without exposing the email address", async () => { | |
| 19 | const cause = Object.assign(new Error("The enrollment was rejected."), { | |
| 20 | code: "form_identifier_invalid", | |
| 21 | }); | |
| 22 | const join = vi.fn().mockResolvedValue({ error: cause }); | |
| 23 | ||
| 24 | const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( | |
| 25 | (error: unknown) => error, | |
| 26 | ); | |
| 27 | ||
| 28 | expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); | |
| 29 | expect(failure).toMatchObject({ | |
| 30 | code: "form_identifier_invalid", | |
| 31 | cause, | |
| 32 | }); | |
| 33 | expect(String(failure)).not.toContain("secret@example.com"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("distinguishes request failures from rejected enrollments", async () => { | |
| 37 | const cause = new Error("network unavailable"); | |
| 38 | const join = vi.fn().mockRejectedValue(cause); | |
| 39 | ||
| 40 | const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( | |
| 41 | (error: unknown) => error, | |
| 42 | ); | |
| 43 | ||
| 44 | expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); | |
| 45 | expect(failure).toMatchObject({ cause }); | |
| 46 | expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); | |
| 47 | }); | |
| 48 | }); |
apps/mobile/src/features/cloud/cloudWaitlistJoin.ts deleted-46| ... | ... | @@ -1,46 +0,0 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | interface CloudWaitlistJoiner { | |
| 4 | readonly join: (input: { emailAddress: string }) => Promise<{ | |
| 5 | readonly error: { readonly code: string } | null; | |
| 6 | }>; | |
| 7 | } | |
| 8 | ||
| 9 | export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass<CloudWaitlistJoinRejectedError>()( | |
| 10 | "CloudWaitlistJoinRejectedError", | |
| 11 | { | |
| 12 | code: Schema.String, | |
| 13 | cause: Schema.Defect(), | |
| 14 | }, | |
| 15 | ) { | |
| 16 | override get message(): string { | |
| 17 | return `Cloud waitlist enrollment was rejected with code "${this.code}".`; | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass<CloudWaitlistJoinRequestError>()( | |
| 22 | "CloudWaitlistJoinRequestError", | |
| 23 | { | |
| 24 | cause: Schema.Defect(), | |
| 25 | }, | |
| 26 | ) { | |
| 27 | override get message(): string { | |
| 28 | return "Cloud waitlist enrollment request failed."; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export async function joinCloudWaitlist( | |
| 33 | waitlist: CloudWaitlistJoiner, | |
| 34 | emailAddress: string, | |
| 35 | ): Promise<void> { | |
| 36 | const result = await waitlist.join({ emailAddress }).catch((cause) => { | |
| 37 | throw new CloudWaitlistJoinRequestError({ cause }); | |
| 38 | }); | |
| 39 | ||
| 40 | if (result.error) { | |
| 41 | throw new CloudWaitlistJoinRejectedError({ | |
| 42 | code: result.error.code, | |
| 43 | cause: result.error, | |
| 44 | }); | |
| 45 | } | |
| 46 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+5-4| ... | ... | @@ -273,13 +273,13 @@ function ConfiguredSettingsRouteScreen() { |
| 273 | 273 | |
| 274 | 274 | const promptSignIn = useCallback(() => { |
| 275 | 275 | Alert.alert( |
| 276 | "Request T3 Connect access", | |
| 277 | "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", | |
| 276 | "Sign in to T3 Connect", | |
| 277 | "Live Activity updates require T3 Connect so relay can deliver updates to this device.", | |
| 278 | 278 | [ |
| 279 | 279 | { text: "Cancel", style: "cancel" }, |
| 280 | 280 | { |
| 281 | 281 | text: "Continue", |
| 282 | onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), | |
| 282 | onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), | |
| 283 | 283 | }, |
| 284 | 284 | ], |
| 285 | 285 | ); |
| ... | ... | @@ -441,7 +441,8 @@ function ConfiguredSettingsRouteScreen() { |
| 441 | 441 | const openAccount = useCallback(() => { |
| 442 | 442 | if (!isLoaded) return; |
| 443 | 443 | if (!isSignedIn) { |
| 444 | navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); | |
| 444 | expandClerkSheet(); | |
| 445 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 445 | 446 | return; |
| 446 | 447 | } |
| 447 | 448 | expandClerkSheet(); |
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx deleted-60| ... | ... | @@ -1,60 +0,0 @@ |
| 1 | import { useAuth } from "@clerk/expo"; | |
| 2 | import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; | |
| 3 | import { useCallback } from "react"; | |
| 4 | import { ScrollView } from "react-native"; | |
| 5 | ||
| 6 | import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; | |
| 7 | import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; | |
| 8 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | |
| 9 | ||
| 10 | export function SettingsWaitlistRouteScreen() { | |
| 11 | const navigation = useNavigation(); | |
| 12 | ||
| 13 | useFocusEffect( | |
| 14 | useCallback(() => { | |
| 15 | if (!hasCloudPublicConfig()) { | |
| 16 | navigation.dispatch(StackActions.replace("Settings")); | |
| 17 | } | |
| 18 | }, [navigation]), | |
| 19 | ); | |
| 20 | ||
| 21 | return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null; | |
| 22 | } | |
| 23 | ||
| 24 | function ConfiguredSettingsWaitlistRouteScreen() { | |
| 25 | const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | |
| 26 | const { expand } = useClerkSettingsSheetDetent(); | |
| 27 | const navigation = useNavigation(); | |
| 28 | ||
| 29 | useFocusEffect( | |
| 30 | useCallback(() => { | |
| 31 | if (isLoaded && isSignedIn) { | |
| 32 | navigation.dispatch(StackActions.replace("Settings")); | |
| 33 | } | |
| 34 | }, [isLoaded, isSignedIn, navigation]), | |
| 35 | ); | |
| 36 | ||
| 37 | return ( | |
| 38 | <> | |
| 39 | <ScrollView | |
| 40 | automaticallyAdjustKeyboardInsets | |
| 41 | contentContainerStyle={{ | |
| 42 | paddingBottom: 32, | |
| 43 | paddingHorizontal: 20, | |
| 44 | paddingTop: 12, | |
| 45 | }} | |
| 46 | contentInsetAdjustmentBehavior="automatic" | |
| 47 | keyboardDismissMode="interactive" | |
| 48 | keyboardShouldPersistTaps="handled" | |
| 49 | showsVerticalScrollIndicator={false} | |
| 50 | > | |
| 51 | <CloudWaitlistEnrollment | |
| 52 | onSignIn={() => { | |
| 53 | expand(); | |
| 54 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | |
| 55 | }} | |
| 56 | /> | |
| 57 | </ScrollView> | |
| 58 | </> | |
| 59 | ); | |
| 60 | } |
apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx+1-1| ... | ... | @@ -3,7 +3,7 @@ import { useClerk } from "@clerk/react"; |
| 3 | 3 | export function useT3ConnectAuthPrompt() { |
| 4 | 4 | const clerk = useClerk(); |
| 5 | 5 | const openAuthPrompt = () => { |
| 6 | clerk.openWaitlist(); | |
| 6 | clerk.openSignIn({ forceRedirectUrl: window.location.href }); | |
| 7 | 7 | }; |
| 8 | 8 | return { authPrompt: null, openAuthPrompt }; |
| 9 | 9 | } |
package.json+1| ... | ... | @@ -38,6 +38,7 @@ |
| 38 | 38 | "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", |
| 39 | 39 | "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", |
| 40 | 40 | "release:smoke": "node scripts/release-smoke.ts", |
| 41 | "connect:announce-ga": "node scripts/announce-connect-ga.ts", | |
| 41 | 42 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", |
| 42 | 43 | "sync:repos": "node scripts/sync-reference-repos.ts" |
| 43 | 44 | }, |
scripts/announce-connect-ga.ts created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import * as Config from "effect/Config"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Logger from "effect/Logger"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { Command, Flag } from "effect/unstable/cli"; | |
| 12 | import { | |
| 13 | FetchHttpClient, | |
| 14 | HttpClient, | |
| 15 | HttpClientRequest, | |
| 16 | HttpClientResponse, | |
| 17 | } from "effect/unstable/http"; | |
| 18 | ||
| 19 | const CLERK_API_URL = "https://api.clerk.com/v1"; | |
| 20 | const PAGE_SIZE = 500; | |
| 21 | ||
| 22 | export class WaitlistEntry extends Schema.Class<WaitlistEntry>("WaitlistEntry")({ | |
| 23 | id: Schema.String, | |
| 24 | email_address: Schema.String, | |
| 25 | status: Schema.Literals(["pending", "invited", "completed", "rejected"]), | |
| 26 | }) {} | |
| 27 | ||
| 28 | const ClerkWaitlistResponse = Schema.Struct({ | |
| 29 | data: Schema.Array(WaitlistEntry), | |
| 30 | total_count: Schema.Int, | |
| 31 | }); | |
| 32 | const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0)); | |
| 33 | const ClerkSecretKey = Config.string("CLERK_SECRET_KEY"); | |
| 34 | ||
| 35 | export interface ConnectGaOptions { | |
| 36 | readonly invite: boolean; | |
| 37 | readonly limit: number | undefined; | |
| 38 | } | |
| 39 | ||
| 40 | export class ConnectGaRequestError extends Schema.TaggedErrorClass<ConnectGaRequestError>()( | |
| 41 | "ConnectGaRequestError", | |
| 42 | { | |
| 43 | operation: Schema.String, | |
| 44 | cause: Schema.Defect(), | |
| 45 | }, | |
| 46 | ) { | |
| 47 | override get message(): string { | |
| 48 | return `Clerk ${this.operation} request failed.`; | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | export class ConnectGaResponseError extends Schema.TaggedErrorClass<ConnectGaResponseError>()( | |
| 53 | "ConnectGaResponseError", | |
| 54 | { | |
| 55 | operation: Schema.String, | |
| 56 | status: Schema.Int, | |
| 57 | cause: Schema.Defect(), | |
| 58 | }, | |
| 59 | ) { | |
| 60 | override get message(): string { | |
| 61 | return `Clerk ${this.operation} returned status ${this.status}.`; | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* < | |
| 66 | S extends Schema.Top, | |
| 67 | >(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) { | |
| 68 | const client = (yield* HttpClient.HttpClient).pipe( | |
| 69 | HttpClient.retryTransient({ | |
| 70 | retryOn: "errors-and-responses", | |
| 71 | times: 3, | |
| 72 | }), | |
| 73 | ); | |
| 74 | const response = yield* client | |
| 75 | .execute(request) | |
| 76 | .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause }))); | |
| 77 | const success = yield* HttpClientResponse.filterStatusOk(response).pipe( | |
| 78 | Effect.mapError( | |
| 79 | (cause) => | |
| 80 | new ConnectGaResponseError({ | |
| 81 | operation, | |
| 82 | status: response.status, | |
| 83 | cause, | |
| 84 | }), | |
| 85 | ), | |
| 86 | ); | |
| 87 | return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe( | |
| 88 | Effect.mapError( | |
| 89 | (cause) => | |
| 90 | new ConnectGaResponseError({ | |
| 91 | operation, | |
| 92 | status: response.status, | |
| 93 | cause, | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | }); | |
| 98 | ||
| 99 | const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* ( | |
| 100 | secretKey: string, | |
| 101 | offset: number, | |
| 102 | ) { | |
| 103 | const url = new URL(`${CLERK_API_URL}/waitlist_entries`); | |
| 104 | url.searchParams.set("status", "pending"); | |
| 105 | url.searchParams.set("limit", String(PAGE_SIZE)); | |
| 106 | url.searchParams.set("offset", String(offset)); | |
| 107 | url.searchParams.set("order_by", "+created_at"); | |
| 108 | const request = HttpClientRequest.get(url.href).pipe( | |
| 109 | HttpClientRequest.bearerToken(secretKey), | |
| 110 | HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), | |
| 111 | ); | |
| 112 | return yield* executeClerkJsonRequest( | |
| 113 | request, | |
| 114 | ClerkWaitlistResponse, | |
| 115 | "list pending waitlist entries", | |
| 116 | ); | |
| 117 | }); | |
| 118 | ||
| 119 | export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* ( | |
| 120 | secretKey: string, | |
| 121 | limit?: number, | |
| 122 | ) { | |
| 123 | const entries: Array<WaitlistEntry> = []; | |
| 124 | while (true) { | |
| 125 | if (limit !== undefined && entries.length >= limit) break; | |
| 126 | const page = yield* fetchWaitlistPage(secretKey, entries.length); | |
| 127 | entries.push(...page.data); | |
| 128 | if (entries.length >= page.total_count || page.data.length === 0) break; | |
| 129 | } | |
| 130 | return limit === undefined ? entries : entries.slice(0, limit); | |
| 131 | }); | |
| 132 | ||
| 133 | export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* ( | |
| 134 | secretKey: string, | |
| 135 | entry: WaitlistEntry, | |
| 136 | ) { | |
| 137 | const request = HttpClientRequest.post( | |
| 138 | `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`, | |
| 139 | ).pipe( | |
| 140 | HttpClientRequest.bearerToken(secretKey), | |
| 141 | HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"), | |
| 142 | ); | |
| 143 | return yield* executeClerkJsonRequest( | |
| 144 | request, | |
| 145 | WaitlistEntry, | |
| 146 | `invite waitlist entry ${entry.id}`, | |
| 147 | ); | |
| 148 | }); | |
| 149 | ||
| 150 | export const announceConnectGa = Effect.fn("announceConnectGa")(function* ( | |
| 151 | options: ConnectGaOptions, | |
| 152 | ) { | |
| 153 | const clerkSecretKey = yield* ClerkSecretKey; | |
| 154 | const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit); | |
| 155 | ||
| 156 | yield* Effect.logInfo( | |
| 157 | options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run", | |
| 158 | ).pipe( | |
| 159 | Effect.annotateLogs({ | |
| 160 | pendingEntries: entries.length, | |
| 161 | }), | |
| 162 | ); | |
| 163 | ||
| 164 | if (!options.invite) { | |
| 165 | for (const entry of entries) { | |
| 166 | yield* Effect.logInfo("pending waitlist entry").pipe( | |
| 167 | Effect.annotateLogs({ | |
| 168 | waitlistEntryId: entry.id, | |
| 169 | emailAddress: entry.email_address, | |
| 170 | }), | |
| 171 | ); | |
| 172 | } | |
| 173 | yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list."); | |
| 174 | return; | |
| 175 | } | |
| 176 | ||
| 177 | for (const [index, entry] of entries.entries()) { | |
| 178 | const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry); | |
| 179 | yield* Effect.logInfo("Clerk waitlist invitation sent").pipe( | |
| 180 | Effect.annotateLogs({ | |
| 181 | waitlistEntryId: invited.id, | |
| 182 | completed: index + 1, | |
| 183 | total: entries.length, | |
| 184 | }), | |
| 185 | ); | |
| 186 | } | |
| 187 | }); | |
| 188 | ||
| 189 | export const announceConnectGaCommand = Command.make( | |
| 190 | "announce-connect-ga", | |
| 191 | { | |
| 192 | invite: Flag.boolean("invite").pipe( | |
| 193 | Flag.withDefault(false), | |
| 194 | Flag.withDescription( | |
| 195 | "Invite pending entries through Clerk. Without this flag, only print a dry-run list.", | |
| 196 | ), | |
| 197 | ), | |
| 198 | limit: Flag.integer("limit").pipe( | |
| 199 | Flag.withSchema(PositiveInteger), | |
| 200 | Flag.optional, | |
| 201 | Flag.withDescription("Process at most this many pending waitlist entries."), | |
| 202 | ), | |
| 203 | }, | |
| 204 | ({ invite, limit }) => | |
| 205 | announceConnectGa({ | |
| 206 | invite, | |
| 207 | limit: Option.getOrUndefined(limit), | |
| 208 | }), | |
| 209 | ).pipe( | |
| 210 | Command.withDescription( | |
| 211 | "Invite pending Clerk waitlist members now that T3 Connect is generally available.", | |
| 212 | ), | |
| 213 | ); | |
| 214 | ||
| 215 | if (import.meta.main) { | |
| 216 | Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe( | |
| 217 | Effect.provide( | |
| 218 | Layer.mergeAll( | |
| 219 | Logger.layer([Logger.consolePretty()]), | |
| 220 | NodeServices.layer, | |
| 221 | FetchHttpClient.layer, | |
| 222 | ), | |
| 223 | ), | |
| 224 | NodeRuntime.runMain, | |
| 225 | ); | |
| 226 | } |