From ac9fc8dfc92cc338f0b5bdd85283d9a6575fda79 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 18 Jun 2026 11:38:54 +0300 Subject: [PATCH] feat(inbox): graduate chat media send to GA (remove beta flag) Photo/video/document/voice send has been validated in production, so it's now available to every account. Removes the 'chat_media' beta gate: - Composer always shows the attach menu + voice recorder (no useBetaFeature check). - Send route no longer 403s media kinds for non-opted-in accounts. - Deletes the now-unused beta-features helper, the useBetaFeature hook, and their test (added solely for this rollout; no other consumer). The profiles.beta_features column (migration 011) is left in place for future betas. typecheck/lint/build green; 411 tests pass. Co-Authored-By: Claude Opus 4.8 --- src/app/api/whatsapp/send/route.ts | 14 +- src/components/inbox/message-composer.tsx | 161 ++++++++++------------ src/hooks/use-beta-feature.ts | 20 --- src/lib/auth/beta-features.test.ts | 19 --- src/lib/auth/beta-features.ts | 31 ----- 5 files changed, 71 insertions(+), 174 deletions(-) delete mode 100644 src/hooks/use-beta-feature.ts delete mode 100644 src/lib/auth/beta-features.test.ts delete mode 100644 src/lib/auth/beta-features.ts diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 31a6383..107c493 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -21,7 +21,6 @@ import { } from '@/lib/rate-limit' import type { MessageTemplate } from '@/types' import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard' -import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features' export async function POST(request: Request) { try { @@ -52,7 +51,7 @@ export async function POST(request: Request) { // returned nothing for teammates who didn't author the row. const { data: profile } = await supabase .from('profiles') - .select('account_id, beta_features') + .select('account_id') .eq('user_id', user.id) .maybeSingle() const accountId = profile?.account_id as string | undefined @@ -120,17 +119,6 @@ export async function POST(request: Request) { ) } - // Media sends are beta-gated (issue #213). Text + templates stay - // available to everyone; only accounts opted into 'chat_media' may - // send media — mirrors the client gate so the endpoint can't be - // driven ahead of the rollout. - if (isMediaKind && !hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) { - return NextResponse.json( - { error: 'Media messages are not enabled for your account.' }, - { status: 403 } - ) - } - // Meta caps media captions at 1024 chars; reject before the upload is // wasted at the Meta call. (Audio carries no caption — see meta-api.) if ( diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 32cdea3..225b258 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -28,8 +28,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useCan } from "@/hooks/use-can"; -import { useBetaFeature } from "@/hooks/use-beta-feature"; -import { CHAT_MEDIA_BETA } from "@/lib/auth/beta-features"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { @@ -162,10 +160,6 @@ export function MessageComposer({ const readOnly = !canSend; // Media (like free-form text) is only allowed inside the 24h window. const inputsDisabled = readOnly || sessionExpired; - // Beta-gated: only accounts opted into 'chat_media' see the attach - // menu / voice recorder. Everyone else gets the original text + template - // composer with no visible change. - const chatMediaEnabled = useBetaFeature(CHAT_MEDIA_BETA); const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -411,42 +405,37 @@ export function MessageComposer({ )} - {/* Hidden file inputs driven by the attach menu — only mounted when - the beta feature is on. */} - {chatMediaEnabled && ( - <> - { - handlePicked("image", e.target.files?.[0]); - e.target.value = ""; - }} - /> - { - handlePicked("video", e.target.files?.[0]); - e.target.value = ""; - }} - /> - { - handlePicked("document", e.target.files?.[0]); - e.target.value = ""; - }} - /> - - )} + {/* Hidden file inputs driven by the attach menu. */} + { + handlePicked("image", e.target.files?.[0]); + e.target.value = ""; + }} + /> + { + handlePicked("video", e.target.files?.[0]); + e.target.value = ""; + }} + /> + { + handlePicked("document", e.target.files?.[0]); + e.target.value = ""; + }} + /> {draft ? ( ) : (
- {/* Attach menu — photo / video / document / voice. Beta-gated: - hidden entirely for accounts not opted into 'chat_media'. */} - {chatMediaEnabled && ( - - - {busy ? ( - - ) : ( - - )} - - - imageInputRef.current?.click()}> - - Photo - - videoInputRef.current?.click()}> - - documentInputRef.current?.click()}> - - Document - - void startRecording()}> - - Voice note - - - - )} + {/* Attach menu — photo / video / document / voice. */} + + + {busy ? ( + + ) : ( + + )} + + + imageInputRef.current?.click()}> + + Photo + + videoInputRef.current?.click()}> + + documentInputRef.current?.click()}> + + Document + + void startRecording()}> + + Voice note + + + +

Type '/' for quick replies

)} diff --git a/src/hooks/use-beta-feature.ts b/src/hooks/use-beta-feature.ts deleted file mode 100644 index bb128ea..0000000 --- a/src/hooks/use-beta-feature.ts +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { useAuth } from "@/hooks/use-auth"; -import { hasBetaFeature, type BetaFeature } from "@/lib/auth/beta-features"; - -/** - * Boolean gate for a per-profile beta feature flag (see - * `@/lib/auth/beta-features`). Mirrors `useCan`: returns `false` while the - * profile is still loading so a not-yet-opted-in user never sees a flash - * of beta UI during the initial load window. - * - * Example: - * const chatMedia = useBetaFeature(CHAT_MEDIA_BETA); - * {chatMedia && } - */ -export function useBetaFeature(feature: BetaFeature): boolean { - const { profileLoading, profile } = useAuth(); - if (profileLoading || !profile) return false; - return hasBetaFeature(profile.beta_features, feature); -} diff --git a/src/lib/auth/beta-features.test.ts b/src/lib/auth/beta-features.test.ts deleted file mode 100644 index b8a374a..0000000 --- a/src/lib/auth/beta-features.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CHAT_MEDIA_BETA, hasBetaFeature } from "./beta-features"; - -describe("hasBetaFeature", () => { - it("is true when the feature key is present", () => { - expect(hasBetaFeature([CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true); - expect(hasBetaFeature(["flows", CHAT_MEDIA_BETA], CHAT_MEDIA_BETA)).toBe(true); - }); - - it("is false when the key is absent", () => { - expect(hasBetaFeature(["flows"], CHAT_MEDIA_BETA)).toBe(false); - expect(hasBetaFeature([], CHAT_MEDIA_BETA)).toBe(false); - }); - - it("is false (not throwing) for null / undefined lists", () => { - expect(hasBetaFeature(null, CHAT_MEDIA_BETA)).toBe(false); - expect(hasBetaFeature(undefined, CHAT_MEDIA_BETA)).toBe(false); - }); -}); diff --git a/src/lib/auth/beta-features.ts b/src/lib/auth/beta-features.ts deleted file mode 100644 index 7da0f58..0000000 --- a/src/lib/auth/beta-features.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Beta feature flags, stored per-profile in `profiles.beta_features` - * (a `TEXT[]`, migration 011). A feature is "on" for an account when its - * key is present in that array; the owner opts in by editing the column - * in Supabase Studio: - * - * UPDATE profiles - * SET beta_features = array_append(beta_features, 'chat_media') - * WHERE email = 'someone@example.com'; - * - * This module is the single source of truth for the keys + the membership - * check so the client hook (`useBetaFeature`) and the server routes gate - * on exactly the same logic. - */ - -/** Agent-initiated media sends in the inbox composer (issue #213). */ -export const CHAT_MEDIA_BETA = "chat_media"; - -export type BetaFeature = typeof CHAT_MEDIA_BETA; - -/** - * Whether an opted-in feature list contains `feature`. Pure + null-safe so - * both the client (reading `profile.beta_features`) and the server - * (reading the row from Supabase) share one implementation. - */ -export function hasBetaFeature( - betaFeatures: readonly string[] | null | undefined, - feature: BetaFeature, -): boolean { - return betaFeatures?.includes(feature) ?? false; -}