| 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,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc |
| 47 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 47 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 48 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 48 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 49 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 49 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 50 | import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; | ||
| 51 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | 50 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 52 | import { | 51 | import { |
| 53 | SettingsLegalDocumentCloseHeaderButton, | 52 | SettingsLegalDocumentCloseHeaderButton, |
| ... | @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({ | ... | @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 190 | }, | 189 | }, |
| 191 | }), | 190 | }), |
| 192 | SettingsWaitlist: createNativeStackScreen({ | 191 | SettingsWaitlist: createNativeStackScreen({ |
| 193 | screen: SettingsWaitlistRouteScreen, | 192 | // Keep the old deep link working after the Connect GA launch. |
| 193 | screen: SettingsAuthRouteScreen, | ||
| 194 | linking: "waitlist", | 194 | linking: "waitlist", |
| 195 | options: { | 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,13 +273,13 @@ function ConfiguredSettingsRouteScreen() { |
| 273 | 273 | ||
| 274 | const promptSignIn = useCallback(() => { | 274 | const promptSignIn = useCallback(() => { |
| 275 | Alert.alert( | 275 | Alert.alert( |
| 276 | "Request T3 Connect access", | 276 | "Sign in to T3 Connect", |
| 277 | "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", | 277 | "Live Activity updates require T3 Connect so relay can deliver updates to this device.", |
| 278 | [ | 278 | [ |
| 279 | { text: "Cancel", style: "cancel" }, | 279 | { text: "Cancel", style: "cancel" }, |
| 280 | { | 280 | { |
| 281 | text: "Continue", | 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,7 +441,8 @@ function ConfiguredSettingsRouteScreen() { |
| 441 | const openAccount = useCallback(() => { | 441 | const openAccount = useCallback(() => { |
| 442 | if (!isLoaded) return; | 442 | if (!isLoaded) return; |
| 443 | if (!isSignedIn) { | 443 | if (!isSignedIn) { |
| 444 | navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); | 444 | expandClerkSheet(); |
| 445 | navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); | ||
| 445 | return; | 446 | return; |
| 446 | } | 447 | } |
| 447 | expandClerkSheet(); | 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,7 +3,7 @@ import { useClerk } from "@clerk/react"; |
| 3 | export function useT3ConnectAuthPrompt() { | 3 | export function useT3ConnectAuthPrompt() { |
| 4 | const clerk = useClerk(); | 4 | const clerk = useClerk(); |
| 5 | const openAuthPrompt = () => { | 5 | const openAuthPrompt = () => { |
| 6 | clerk.openWaitlist(); | 6 | clerk.openSignIn({ forceRedirectUrl: window.location.href }); |
| 7 | }; | 7 | }; |
| 8 | return { authPrompt: null, openAuthPrompt }; | 8 | return { authPrompt: null, openAuthPrompt }; |
| 9 | } | 9 | } |
package.json+1| ... | @@ -38,6 +38,7 @@ | ... | @@ -38,6 +38,7 @@ |
| 38 | "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", | 38 | "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", |
| 39 | "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", | 39 | "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", |
| 40 | "release:smoke": "node scripts/release-smoke.ts", | 40 | "release:smoke": "node scripts/release-smoke.ts", |
| 41 | "connect:announce-ga": "node scripts/announce-connect-ga.ts", | ||
| 41 | "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 | "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 | "sync:repos": "node scripts/sync-reference-repos.ts" | 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 | } | ||