mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #263 from ArnasDon/feat/chat-media-ga
feat(inbox): graduate chat media send to GA (remove beta flag)
This commit is contained in:
commit
60466d14b7
@ -21,7 +21,6 @@ import {
|
|||||||
} from '@/lib/rate-limit'
|
} from '@/lib/rate-limit'
|
||||||
import type { MessageTemplate } from '@/types'
|
import type { MessageTemplate } from '@/types'
|
||||||
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
|
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
|
||||||
import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features'
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -52,7 +51,7 @@ export async function POST(request: Request) {
|
|||||||
// returned nothing for teammates who didn't author the row.
|
// returned nothing for teammates who didn't author the row.
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('account_id, beta_features')
|
.select('account_id')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
const accountId = profile?.account_id as string | undefined
|
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
|
// Meta caps media captions at 1024 chars; reject before the upload is
|
||||||
// wasted at the Meta call. (Audio carries no caption — see meta-api.)
|
// wasted at the Meta call. (Audio carries no caption — see meta-api.)
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -28,8 +28,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useCan } from "@/hooks/use-can";
|
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 { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -162,10 +160,6 @@ export function MessageComposer({
|
|||||||
const readOnly = !canSend;
|
const readOnly = !canSend;
|
||||||
// Media (like free-form text) is only allowed inside the 24h window.
|
// Media (like free-form text) is only allowed inside the 24h window.
|
||||||
const inputsDisabled = readOnly || sessionExpired;
|
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(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
@ -411,42 +405,37 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden file inputs driven by the attach menu — only mounted when
|
{/* Hidden file inputs driven by the attach menu. */}
|
||||||
the beta feature is on. */}
|
<input
|
||||||
{chatMediaEnabled && (
|
ref={imageInputRef}
|
||||||
<>
|
type="file"
|
||||||
<input
|
accept={PICKER_ACCEPT.image}
|
||||||
ref={imageInputRef}
|
className="hidden"
|
||||||
type="file"
|
onChange={(e) => {
|
||||||
accept={PICKER_ACCEPT.image}
|
handlePicked("image", e.target.files?.[0]);
|
||||||
className="hidden"
|
e.target.value = "";
|
||||||
onChange={(e) => {
|
}}
|
||||||
handlePicked("image", e.target.files?.[0]);
|
/>
|
||||||
e.target.value = "";
|
<input
|
||||||
}}
|
ref={videoInputRef}
|
||||||
/>
|
type="file"
|
||||||
<input
|
accept={PICKER_ACCEPT.video}
|
||||||
ref={videoInputRef}
|
className="hidden"
|
||||||
type="file"
|
onChange={(e) => {
|
||||||
accept={PICKER_ACCEPT.video}
|
handlePicked("video", e.target.files?.[0]);
|
||||||
className="hidden"
|
e.target.value = "";
|
||||||
onChange={(e) => {
|
}}
|
||||||
handlePicked("video", e.target.files?.[0]);
|
/>
|
||||||
e.target.value = "";
|
<input
|
||||||
}}
|
ref={documentInputRef}
|
||||||
/>
|
type="file"
|
||||||
<input
|
accept={PICKER_ACCEPT.document}
|
||||||
ref={documentInputRef}
|
className="hidden"
|
||||||
type="file"
|
onChange={(e) => {
|
||||||
accept={PICKER_ACCEPT.document}
|
handlePicked("document", e.target.files?.[0]);
|
||||||
className="hidden"
|
e.target.value = "";
|
||||||
onChange={(e) => {
|
}}
|
||||||
handlePicked("document", e.target.files?.[0]);
|
/>
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<MediaDraftPreview
|
<MediaDraftPreview
|
||||||
@ -483,47 +472,44 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
{/* Attach menu — photo / video / document / voice. Beta-gated:
|
{/* Attach menu — photo / video / document / voice. */}
|
||||||
hidden entirely for accounts not opted into 'chat_media'. */}
|
<DropdownMenu>
|
||||||
{chatMediaEnabled && (
|
<DropdownMenuTrigger
|
||||||
<DropdownMenu>
|
disabled={inputsDisabled || busy}
|
||||||
<DropdownMenuTrigger
|
title={
|
||||||
disabled={inputsDisabled || busy}
|
readOnly
|
||||||
title={
|
? "Read-only — your role can't send messages"
|
||||||
readOnly
|
: inputsDisabled
|
||||||
? "Read-only — your role can't send messages"
|
? undefined
|
||||||
: inputsDisabled
|
: "Attach media"
|
||||||
? undefined
|
}
|
||||||
: "Attach media"
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
}
|
>
|
||||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
{busy ? (
|
||||||
>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{busy ? (
|
) : (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Paperclip className="h-4 w-4" />
|
||||||
) : (
|
)}
|
||||||
<Paperclip className="h-4 w-4" />
|
</DropdownMenuTrigger>
|
||||||
)}
|
<DropdownMenuContent align="start" className="border-border bg-popover">
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
|
||||||
<DropdownMenuContent align="start" className="border-border bg-popover">
|
<ImageIcon className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
|
Photo
|
||||||
<ImageIcon className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
Photo
|
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
|
||||||
</DropdownMenuItem>
|
<Video className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
|
Video
|
||||||
<Video className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
Video
|
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
|
||||||
</DropdownMenuItem>
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
|
Document
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
Document
|
<DropdownMenuItem onClick={() => void startRecording()}>
|
||||||
</DropdownMenuItem>
|
<Mic className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => void startRecording()}>
|
Voice note
|
||||||
<Mic className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
Voice note
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</DropdownMenu>
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GatedButton
|
<GatedButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -578,14 +564,7 @@ export function MessageComposer({
|
|||||||
`items-end` buttons below the textarea. Indented to line up
|
`items-end` buttons below the textarea. Indented to line up
|
||||||
under the textarea left edge. */}
|
under the textarea left edge. */}
|
||||||
{!draft && !recording && (
|
{!draft && !recording && (
|
||||||
<p
|
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
|
||||||
className={cn(
|
|
||||||
"mt-1 text-[10px] text-muted-foreground",
|
|
||||||
// Line up under the textarea: one button (template) when the
|
|
||||||
// attach menu is hidden, two when the beta feature adds it.
|
|
||||||
chatMediaEnabled ? "pl-[5.5rem]" : "pl-11",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Type '/' for quick replies
|
Type '/' for quick replies
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 && <AttachButton />}
|
|
||||||
*/
|
|
||||||
export function useBetaFeature(feature: BetaFeature): boolean {
|
|
||||||
const { profileLoading, profile } = useAuth();
|
|
||||||
if (profileLoading || !profile) return false;
|
|
||||||
return hasBetaFeature(profile.beta_features, feature);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user