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 | 28 | * user-facing request. |
| 29 | 29 | */ |
| 30 | 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 | 45 | return { |
| 45 | 46 | use: () => notConfigured, |
| 46 | 47 | trackExecution: () => Effect.void, |
| 48 | + trackMemberChange: () => Effect.void, | |
| 47 | 49 | } satisfies IAutumnService; |
| 48 | 50 | } |
| 49 | 51 | |
| @@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines | ||
| 59 | 61 | Effect.gen(function* () { |
| 60 | 62 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); |
| 61 | 63 | const outcome = yield* Effect.result( |
| 62 | − use((c) => | |
| 63 | − c.track({ customerId: organizationId, featureId: "executions", value: 1 }), | |
| 64 | − ), | |
| 64 | + use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), | |
| 65 | 65 | ); |
| 66 | 66 | if (outcome._tag === "Failure") { |
| 67 | 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 | 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< | |
| 79 | − AutumnService, | |
| 80 | − IAutumnService | |
| 81 | −>()("@executor-js/cloud/AutumnService") { | |
| 94 | +export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( | |
| 95 | + "@executor-js/cloud/AutumnService", | |
| 96 | +) { | |
| 82 | 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 | +} | |