History

Switch billing from per-seat to flat-fee per org + add Slack/email contact

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.

Rhys Sullivan committed 6 months ago

Commit 47dfd6b1 parent c5d93a317 files · +919 −189Browse files

Showing the changes to apps/cloud/src/services (2 of 17 files). Show all

Unified · Split

2 files
  1. apps/cloud/src/services/autumn.ts +23 −8
  2. apps/cloud/src/services/slack.ts +142 −0
apps/cloud/src/services/autumn.ts+23−8
@@ -28,6 +28,7 @@ export type IAutumnService = Readonly<{ · 27 unmodified lines
2828 * user-facing request.
2929 */
3030 trackExecution: (organizationId: string) => Effect.Effect<void, never, never>;
31+ trackMemberChange: (organizationId: string, value: number) => Effect.Effect<void, never, never>;
3132 }>;
3233
3334 // ---------------------------------------------------------------------------
@@ -44,6 +45,7 @@ const make = Effect.sync(() => { · 10 unmodified lines
4445 return {
4546 use: () => notConfigured,
4647 trackExecution: () => Effect.void,
48+ trackMemberChange: () => Effect.void,
4749 } satisfies IAutumnService;
4850 }
4951
@@ -59,9 +61,7 @@ const make = Effect.sync(() => { · 9 unmodified lines
5961 Effect.gen(function* () {
6062 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
6163 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 })),
6565 );
6666 if (outcome._tag === "Failure") {
6767 // Silent billing data loss is worth paging on — autumn.trackExecution
@@ -72,12 +72,27 @@ const make = Effect.sync(() => { · 4 unmodified lines
7272 }
7373 }).pipe(Effect.withSpan("autumn.trackExecution"));
7474
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;
7692 });
7793
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+) {
8297 static Default = Layer.effect(this)(make);
8398 }
apps/cloud/src/services/slack.ts+142−0
@@ -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+ email
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+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t