Pricing model - Replace seat-based pricing with org-flat-fee plans: Free (10k execs/mo, 3 members), Free PAYG (10k included + $0.20/1k overage), Team ($49/mo, 250k incl, unlimited members, $0.20/1k overage). Bumped Free included executions from 5k -> 10k for top-of-funnel. - Mark `seats` feature archived; introduce `members` (non-consumable metered) and `domain-verification` (boolean) features. - Update privacy/terms copy from "seat counts" to "membership" / "organization-level" pricing language. - Refresh /billing and /billing/plans UI: trim per-seat add-ons, render Free/Team/Enterprise cards, fix button alignment under conditional badges. Slack/email contact CTA on pricing page - New SlackService wraps Slack Web API: creates `#shared-<email-slug>` channel (retries with random suffix on `name_taken`), posts a hello message, and calls `conversations.inviteShared` with `external_limited`. - POST /api/contact/slack: public endpoint, validates email, verifies a Cloudflare Turnstile token, gates on a 5/min global ratelimit binding, then calls SlackService. Internal SlackError details are logged server-side; the client only sees a generic message. - Pricing page CTA: dialog with Turnstile widget + email/name/note form, prefilled from useAuth() when signed in. Sibling "Email us" mailto: link to rhys@executor.sh. - Wrangler bindings: ratelimit (5/60s), VITE_PUBLIC_TURNSTILE_SITEKEY vars; .env.op gets dev test keys + SLACK_BOT_TOKEN op:// reference.
Showing the changes to apps/cloud/src/services (2 of 17 files). Show all
Unified · Split
| @@ -28,6 +28,7 @@ export type IAutumnService = Readonly<{ · 27 unmodified lines | |||
| 28 | * user-facing request. | 28 | * user-facing request. |
| 29 | */ | 29 | */ |
| 30 | trackExecution: (organizationId: string) => Effect.Effect<void, never, never>; | 30 | trackExecution: (organizationId: string) => Effect.Effect<void, never, never>; |
| 31 | trackMemberChange: (organizationId: string, value: number) => Effect.Effect<void, never, never>; | ||
| 31 | }>; | 32 | }>; |
| 32 | 33 | ||
| 33 | // --------------------------------------------------------------------------- | 34 | // --------------------------------------------------------------------------- |
| @@ -44,6 +45,7 @@ const make = Effect.sync(() => { · 10 unmodified lines | |||
| 44 | return { | 45 | return { |
| 45 | use: () => notConfigured, | 46 | use: () => notConfigured, |
| 46 | trackExecution: () => Effect.void, | 47 | trackExecution: () => Effect.void, |
| 48 | trackMemberChange: () => Effect.void, | ||
| 47 | } satisfies IAutumnService; | 49 | } satisfies IAutumnService; |
| 48 | } | 50 | } |
| 49 | 51 | ||
| @@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines | |||
| 59 | Effect.gen(function* () { | 61 | Effect.gen(function* () { |
| 60 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); | 62 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); |
| 61 | const outcome = yield* Effect.result( | 63 | const outcome = yield* Effect.result( |
| 62 | use((c) => | 64 | use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), |
| 63 | c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | ||
| 64 | ), | ||
| 65 | ); | 65 | ); |
| 66 | if (outcome._tag === "Failure") { | 66 | if (outcome._tag === "Failure") { |
| 67 | // Silent billing data loss is worth paging on — autumn.trackExecution | 67 | // Silent billing data loss is worth paging on — autumn.trackExecution |
| @@ -72,12 +72,27 @@ const make = Effect.sync(() => { · 4 unmodified lines | |||
| 72 | } | 72 | } |
| 73 | }).pipe(Effect.withSpan("autumn.trackExecution")); | 73 | }).pipe(Effect.withSpan("autumn.trackExecution")); |
| 74 | 74 | ||
| 75 | return { use, trackExecution } satisfies IAutumnService; | 75 | const trackMemberChange = (organizationId: string, value: number) => |
| 76 | Effect.gen(function* () { | ||
| 77 | yield* Effect.annotateCurrentSpan({ | ||
| 78 | "autumn.customer.id": organizationId, | ||
| 79 | "autumn.members.delta": value, | ||
| 80 | }); | ||
| 81 | const outcome = yield* Effect.result( | ||
| 82 | use((c) => c.track({ customerId: organizationId, featureId: "members", value })), | ||
| 83 | ); | ||
| 84 | if (outcome._tag === "Failure") { | ||
| 85 | console.error("[billing] member tracking failed:", outcome.failure); | ||
| 86 | Sentry.captureException(outcome.failure); | ||
| 87 | yield* Effect.annotateCurrentSpan({ "autumn.track.members.failed": true }); | ||
| 88 | } | ||
| 89 | }).pipe(Effect.withSpan("autumn.trackMemberChange")); | ||
| 90 | |||
| 91 | return { use, trackExecution, trackMemberChange } satisfies IAutumnService; | ||
| 76 | }); | 92 | }); |
| 77 | 93 | ||
| 78 | export class AutumnService extends Context.Service< | 94 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( |
| 79 | AutumnService, | 95 | "@executor-js/cloud/AutumnService", |
| 80 | IAutumnService | 96 | ) { |
| 81 | >()("@executor-js/cloud/AutumnService") { | ||
| 82 | static Default = Layer.effect(this)(make); | 97 | static Default = Layer.effect(this)(make); |
| 83 | } | 98 | } |
| @@ -0,0 +1,142 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Slack service — creates a per-customer Slack Connect channel and emails an | ||
| 3 | // invite. Used by the pricing-page "Get in touch on Slack" CTA, modeled on | ||
| 4 | // WorkOS's onboarding flow. | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | import { env } from "cloudflare:workers"; | ||
| 8 | import { Context, Data, Effect, Layer } from "effect"; | ||
| 9 | |||
| 10 | export class SlackError extends Data.TaggedError("SlackError")<{ | ||
| 11 | method: string; | ||
| 12 | error: string; | ||
| 13 | }> {} | ||
| 14 | |||
| 15 | export type ISlackService = Readonly<{ | ||
| 16 | createConnectInvite: (input: { | ||
| 17 | email: string; | ||
| 18 | name?: string; | ||
| 19 | note?: string; | ||
| 20 | organization?: string; | ||
| 21 | }) => Effect.Effect< | ||
| 22 | { | ||
| 23 | channel: { id: string; name: string }; | ||
| 24 | invite: { invite_id: string; url: string }; | ||
| 25 | }, | ||
| 26 | SlackError | ||
| 27 | >; | ||
| 28 | }>; | ||
| 29 | |||
| 30 | const slugifyEmail = (email: string): string => | ||
| 31 | |||
| 32 | .toLowerCase() | ||
| 33 | .replace(/[^a-z0-9]+/g, "-") | ||
| 34 | .replace(/^-+|-+$/g, "") | ||
| 35 | .slice(0, 40); | ||
| 36 | |||
| 37 | const randomSuffix = (): string => { | ||
| 38 | const bytes = new Uint8Array(3); | ||
| 39 | crypto.getRandomValues(bytes); | ||
| 40 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); | ||
| 41 | }; | ||
| 42 | |||
| 43 | type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>; | ||
| 44 | |||
| 45 | const make = Effect.sync(() => { | ||
| 46 | const token = env.SLACK_BOT_TOKEN; | ||
| 47 | |||
| 48 | if (!token) { | ||
| 49 | const notConfigured = (method: string) => | ||
| 50 | Effect.fail( | ||
| 51 | new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }), | ||
| 52 | ); | ||
| 53 | return { | ||
| 54 | createConnectInvite: () => notConfigured("createConnectInvite"), | ||
| 55 | } satisfies ISlackService; | ||
| 56 | } | ||
| 57 | |||
| 58 | const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) => | ||
| 59 | Effect.tryPromise({ | ||
| 60 | try: async (): Promise<A> => { | ||
| 61 | const res = await fetch(`https://slack.com/api/${method}`, { | ||
| 62 | method: "POST", | ||
| 63 | headers: { | ||
| 64 | "content-type": "application/json; charset=utf-8", | ||
| 65 | authorization: `Bearer ${token}`, | ||
| 66 | }, | ||
| 67 | body: JSON.stringify(body), | ||
| 68 | }); | ||
| 69 | const json = (await res.json()) as A; | ||
| 70 | if (!json.ok) throw new Error(json.error ?? "unknown_slack_error"); | ||
| 71 | return json; | ||
| 72 | }, | ||
| 73 | catch: (cause) => | ||
| 74 | new SlackError({ | ||
| 75 | method, | ||
| 76 | error: cause instanceof Error ? cause.message : String(cause), | ||
| 77 | }), | ||
| 78 | }).pipe(Effect.withSpan(`slack.${method}`)); | ||
| 79 | |||
| 80 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ | ||
| 81 | email, | ||
| 82 | name, | ||
| 83 | note, | ||
| 84 | organization, | ||
| 85 | }) => | ||
| 86 | Effect.gen(function* () { | ||
| 87 | // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace. | ||
| 88 | const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80); | ||
| 89 | const tryCreate = (n: string) => | ||
| 90 | call<SlackResponse & { channel: { id: string; name: string } }>( | ||
| 91 | "conversations.create", | ||
| 92 | { name: n, is_private: false }, | ||
| 93 | ); | ||
| 94 | |||
| 95 | const created = yield* tryCreate(baseName).pipe( | ||
| 96 | Effect.catchTag("SlackError", (err) => | ||
| 97 | err.error === "name_taken" | ||
| 98 | ? tryCreate(`${baseName}-${randomSuffix()}`.slice(0, 80)) | ||
| 99 | : Effect.fail(err), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | const channel = created.channel; | ||
| 103 | |||
| 104 | const helloLines = [ | ||
| 105 | `:wave: New contact from pricing page: ${email}`, | ||
| 106 | name ? `Name: ${name}` : null, | ||
| 107 | organization ? `Org: ${organization}` : null, | ||
| 108 | note ? `Note: ${note}` : null, | ||
| 109 | ].filter(Boolean) as string[]; | ||
| 110 | |||
| 111 | yield* call<SlackResponse>("chat.postMessage", { | ||
| 112 | channel: channel.id, | ||
| 113 | text: helloLines.join("\n"), | ||
| 114 | }); | ||
| 115 | |||
| 116 | const invite = yield* call<SlackResponse & { invite_id: string; url: string }>( | ||
| 117 | "conversations.inviteShared", | ||
| 118 | { | ||
| 119 | channel: channel.id, | ||
| 120 | emails: [email], | ||
| 121 | // `external_limited: true` scopes the guest to just this channel, | ||
| 122 | // which is what we want for a focused 1:1 support conversation. | ||
| 123 | external_limited: true, | ||
| 124 | }, | ||
| 125 | ); | ||
| 126 | |||
| 127 | return { | ||
| 128 | channel: { id: channel.id, name: channel.name }, | ||
| 129 | invite: { invite_id: invite.invite_id, url: invite.url }, | ||
| 130 | }; | ||
| 131 | }).pipe( | ||
| 132 | Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }), | ||
| 133 | ); | ||
| 134 | |||
| 135 | return { createConnectInvite } satisfies ISlackService; | ||
| 136 | }); | ||
| 137 | |||
| 138 | export class SlackService extends Context.Service<SlackService, ISlackService>()( | ||
| 139 | "@executor-js/cloud/SlackService", | ||
| 140 | ) { | ||
| 141 | static Default = Layer.effect(this)(make); | ||
| 142 | } | ||