diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 107c493..31a6383 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -21,6 +21,7 @@ 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 { @@ -51,7 +52,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') + .select('account_id, beta_features') .eq('user_id', user.id) .maybeSingle() const accountId = profile?.account_id as string | undefined @@ -119,6 +120,17 @@ 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/app/api/whatsapp/transcode-audio/route.ts b/src/app/api/whatsapp/transcode-audio/route.ts index 23a5ba7..c6de773 100644 --- a/src/app/api/whatsapp/transcode-audio/route.ts +++ b/src/app/api/whatsapp/transcode-audio/route.ts @@ -11,6 +11,7 @@ import { RATE_LIMITS, } from '@/lib/rate-limit' import { buildFfmpegArgs } from '@/lib/whatsapp/audio-transcode' +import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features' // ffmpeg + temp-file I/O need the Node runtime (not Edge). export const runtime = 'nodejs' @@ -69,6 +70,20 @@ export async function POST(request: Request) { return rateLimitResponse(limit) } + // Voice notes are part of the beta-gated media feature — only opted-in + // accounts may transcode (mirrors the send route + client gate). + const { data: profile } = await supabase + .from('profiles') + .select('beta_features') + .eq('user_id', user.id) + .maybeSingle() + if (!hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) { + return NextResponse.json( + { error: 'Voice notes are not enabled for your account.' }, + { status: 403 }, + ) + } + const ffmpegPath = resolveFfmpegPath() if (!ffmpegPath) { console.error('[transcode-audio] ffmpeg binary not found') diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 396e8f1..4f69f2c 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -28,6 +28,8 @@ 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 { @@ -165,6 +167,10 @@ 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) { @@ -427,37 +433,42 @@ export function MessageComposer({ )} - {/* 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 = ""; - }} - /> + {/* 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 = ""; + }} + /> + + )} {draft ? ( ) : (
- {/* Attach menu — photo / video / document / voice. */} - - - {busy ? ( - - ) : ( - - )} - - - imageInputRef.current?.click()}> - - Photo - - videoInputRef.current?.click()}> - - documentInputRef.current?.click()}> - - Document - - void startRecording()}> - - Voice note - - - + {/* 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 + + + + )} +

Type '/' for quick replies

)} diff --git a/src/hooks/use-beta-feature.ts b/src/hooks/use-beta-feature.ts new file mode 100644 index 0000000..bb128ea --- /dev/null +++ b/src/hooks/use-beta-feature.ts @@ -0,0 +1,20 @@ +"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 new file mode 100644 index 0000000..b8a374a --- /dev/null +++ b/src/lib/auth/beta-features.test.ts @@ -0,0 +1,19 @@ +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 new file mode 100644 index 0000000..7da0f58 --- /dev/null +++ b/src/lib/auth/beta-features.ts @@ -0,0 +1,31 @@ +/** + * 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; +}