Merge pull request #134 from ArnasDon/feat/flows-open-to-all

feat(flows): soft-GA — open to all users + Beta label
This commit is contained in:
Arnas Donauskas 2026-05-22 17:19:24 +03:00 committed by GitHub
commit 515cf6427f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 103 additions and 377 deletions

View File

@ -5,8 +5,6 @@ import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { FlowBuilder } from "@/components/flows/flow-builder"; import { FlowBuilder } from "@/components/flows/flow-builder";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -17,31 +15,21 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
* `<FlowBuilder>`. Owns the loading/error state so the builder can * `<FlowBuilder>`. Owns the loading/error state so the builder can
* focus purely on editing. * focus purely on editing.
* *
* Beta gate: client-side bounce for the snappy redirect, server-side * Open to every authenticated user — the beta gate that previously
* 404 on the API as the real security boundary. * 404'd non-beta accounts was removed in PR #134. The API still
* 404s on a flow id the caller doesn't own (RLS), which becomes the
* "Flow not found" state below.
*/ */
export default function FlowEditorPage() { export default function FlowEditorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flow, setFlow] = useState<FlowRow | null>(null); const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]); const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const allowed = isFlowsEnabled(profile);
useEffect(() => { useEffect(() => {
// Wait for BOTH session and profile — see the comment on the same
// gate in /flows/page.tsx. Without `profileLoading`, navigating
// here from the list shows the `{ loading: false, profile: null }`
// window and would bounce a legitimate beta user.
if (authLoading || profileLoading) return;
if (!allowed) {
router.replace("/dashboard");
return;
}
if (!params.id) return; if (!params.id) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@ -72,9 +60,9 @@ export default function FlowEditorPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [allowed, authLoading, profileLoading, params.id, router]); }, [params.id]);
if (authLoading || profileLoading || (allowed && loading)) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" /> <Loader2 className="h-6 w-6 animate-spin text-slate-500" />

View File

@ -17,8 +17,6 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -98,7 +96,6 @@ const STATUS_META: Record<
export default function FlowRunsPage() { export default function FlowRunsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]); const [runs, setRuns] = useState<RunRow[]>([]);
@ -107,15 +104,7 @@ export default function FlowRunsPage() {
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const allowed = isFlowsEnabled(profile);
useEffect(() => { useEffect(() => {
// Wait for BOTH session and profile — see /flows/page.tsx.
if (authLoading || profileLoading) return;
if (!allowed) {
router.replace("/dashboard");
return;
}
if (!params.id) return; if (!params.id) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@ -148,7 +137,7 @@ export default function FlowRunsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [allowed, authLoading, profileLoading, params.id, router]); }, [params.id]);
function toggle(runId: string) { function toggle(runId: string) {
setExpanded((prev) => { setExpanded((prev) => {
@ -159,7 +148,7 @@ export default function FlowRunsPage() {
}); });
} }
if (authLoading || profileLoading || (allowed && loading)) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" /> <Loader2 className="h-6 w-6 animate-spin text-slate-500" />

View File

@ -18,8 +18,6 @@ import {
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -36,10 +34,9 @@ import { cn } from "@/lib/utils";
/** /**
* Flows list page. * Flows list page.
* *
* Gated client-side on `isFlowsEnabled(profile)` AND server-side by * Open to every authenticated user. Flows is in soft-GA — the "Beta"
* every /api/flows endpoint returning 404 to non-beta accounts. The * chip in the header is the only remaining signal that the surface
* client gate hides the page from non-beta users who land here via * is new. The previous per-account beta gate was removed in PR #134.
* URL-typing; the server gate is the real security boundary.
*/ */
interface FlowRow { interface FlowRow {
@ -84,7 +81,6 @@ const TEMPLATE_ICONS = {
export default function FlowsPage() { export default function FlowsPage() {
const router = useRouter(); const router = useRouter();
const { profile, loading: authLoading, profileLoading } = useAuth();
const [flows, setFlows] = useState<FlowRow[]>([]); const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -92,20 +88,7 @@ export default function FlowsPage() {
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [templates, setTemplates] = useState<TemplateSummary[]>([]); const [templates, setTemplates] = useState<TemplateSummary[]>([]);
const flowsAccessAllowed = isFlowsEnabled(profile);
useEffect(() => { useEffect(() => {
// Wait for BOTH the session and the profile row. Session loads
// fast (cookie read) but profile crosses the network — during the
// gap `profile` is null and `isFlowsEnabled(null)` is false, which
// would bounce a legitimate beta user back to /dashboard on every
// navigation here.
if (authLoading || profileLoading) return;
if (!flowsAccessAllowed) {
// Bounce non-beta users — the API would 404 every call anyway.
router.replace("/dashboard");
return;
}
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
@ -138,7 +121,7 @@ export default function FlowsPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [authLoading, profileLoading, flowsAccessAllowed, router]); }, []);
async function handleCreate() { async function handleCreate() {
if (!newName.trim()) return; if (!newName.trim()) return;
@ -205,7 +188,7 @@ export default function FlowsPage() {
} }
} }
if (authLoading || profileLoading || (flowsAccessAllowed && loading)) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" /> <Loader2 className="h-6 w-6 animate-spin text-slate-500" />
@ -217,7 +200,12 @@ export default function FlowsPage() {
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
<header className="flex flex-wrap items-end justify-between gap-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="text-2xl font-semibold text-white">Flows</h1> <div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-white">Flows</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta
</span>
</div>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-slate-400">
Build branching, button-driven WhatsApp conversations. Useful for Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in. menus, FAQs, and triage before a human steps in.

View File

@ -1,7 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { validateFlowForActivation } from '@/lib/flows/validate' import { validateFlowForActivation } from '@/lib/flows/validate'
/** /**
@ -31,14 +30,6 @@ export async function POST(
if (!user) { if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const body = (await request.json().catch(() => null)) as const body = (await request.json().catch(() => null)) as
| { status?: 'draft' | 'active' | 'archived' } | { status?: 'draft' | 'active' | 'archived' }

View File

@ -1,7 +1,6 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
/** /**
* GET /api/flows/[id] — fetch one flow with its nodes. * GET /api/flows/[id] — fetch one flow with its nodes.
@ -13,10 +12,12 @@ import { isFlowsEnabled } from '@/lib/flows/feature-flag'
* DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes, * DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes,
* runs, events). * runs, events).
* *
* All three are 404 for non-beta accounts. * All three require a signed-in caller who owns the flow. Flows is in
* soft-GA — the beta gate that previously 404'd non-beta accounts is
* gone; the "Beta" label in the UI is the only remaining signal.
*/ */
async function requireFlowsBetaAndOwnership( async function requireOwnership(
flowId: string, flowId: string,
): Promise< ): Promise<
| { | {
@ -33,14 +34,6 @@ async function requireFlowsBetaAndOwnership(
if (!user) { if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } } return { ok: false, status: 401, body: { error: 'Unauthorized' } }
} }
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return { ok: false, status: 404, body: { error: 'Not found' } }
}
// RLS scopes this to the caller — a flow owned by another user // RLS scopes this to the caller — a flow owned by another user
// returns null (404 below). // returns null (404 below).
const { data: flow } = await supabase const { data: flow } = await supabase
@ -59,7 +52,7 @@ export async function GET(
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
const { id } = await context.params const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id) const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const { supabase } = guard const { supabase } = guard
@ -98,7 +91,7 @@ export async function PUT(
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
const { id } = await context.params const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id) const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
const body = (await request.json().catch(() => null)) as PutBody | null const body = (await request.json().catch(() => null)) as PutBody | null
@ -184,7 +177,7 @@ export async function DELETE(
context: { params: Promise<{ id: string }> }, context: { params: Promise<{ id: string }> },
) { ) {
const { id } = await context.params const { id } = await context.params
const guard = await requireFlowsBetaAndOwnership(id) const guard = await requireOwnership(id)
if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status })
// CASCADE on flow_nodes / flow_runs / flow_run_events handles the // CASCADE on flow_nodes / flow_runs / flow_run_events handles the

View File

@ -1,6 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
/** /**
* GET /api/flows/[id]/runs * GET /api/flows/[id]/runs
@ -30,14 +29,6 @@ export async function GET(
if (!user) { if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Confirm flow exists + caller owns it (RLS does this) before doing // Confirm flow exists + caller owns it (RLS does this) before doing
// the run query — gives us a clean 404 instead of empty array. // the run query — gives us a clean 404 instead of empty array.

View File

@ -1,19 +1,19 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { getFlowTemplate } from '@/lib/flows/templates' import { getFlowTemplate } from '@/lib/flows/templates'
/** /**
* GET /api/flows — list the caller's flows. * GET /api/flows — list the caller's flows.
* POST /api/flows — create a new (draft) flow. * POST /api/flows — create a new (draft) flow.
* *
* Both endpoints gate on the per-account Flows beta flag. Non-beta * Available to every authenticated user. The previous per-account
* accounts get a 404 so the UI / a curious user can't discover the * beta gate was removed when Flows went to soft-GA; the UI still
* surface ahead of GA. * shows a "Beta" label so users know the surface is young, but the
* routes themselves are open.
*/ */
async function requireFlowsBeta(): Promise< async function requireUser(): Promise<
| { ok: true; userId: string; supabase: Awaited<ReturnType<typeof createClient>> } | { ok: true; userId: string; supabase: Awaited<ReturnType<typeof createClient>> }
| { ok: false; status: number; body: { error: string } } | { ok: false; status: number; body: { error: string } }
> { > {
@ -24,23 +24,11 @@ async function requireFlowsBeta(): Promise<
if (!user) { if (!user) {
return { ok: false, status: 401, body: { error: 'Unauthorized' } } return { ok: false, status: 401, body: { error: 'Unauthorized' } }
} }
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (
!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)
) {
// 404 (not 403) so the route looks like it doesn't exist to
// non-beta accounts — keeps the UI invisible.
return { ok: false, status: 404, body: { error: 'Not found' } }
}
return { ok: true, userId: user.id, supabase } return { ok: true, userId: user.id, supabase }
} }
export async function GET() { export async function GET() {
const guard = await requireFlowsBeta() const guard = await requireUser()
if (!guard.ok) { if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status }) return NextResponse.json(guard.body, { status: guard.status })
} }
@ -57,7 +45,7 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const guard = await requireFlowsBeta() const guard = await requireUser()
if (!guard.ok) { if (!guard.ok) {
return NextResponse.json(guard.body, { status: guard.status }) return NextResponse.json(guard.body, { status: guard.status })
} }

View File

@ -1,6 +1,5 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { listFlowTemplates } from '@/lib/flows/templates' import { listFlowTemplates } from '@/lib/flows/templates'
/** /**
@ -11,8 +10,7 @@ import { listFlowTemplates } from '@/lib/flows/templates'
* without bundling the full template payloads client-side. Bodies * without bundling the full template payloads client-side. Bodies
* are fetched only on actual clone via POST /api/flows. * are fetched only on actual clone via POST /api/flows.
* *
* 404 to non-beta accounts (matches the rest of the /api/flows * Available to any signed-in user. Flows is in soft-GA.
* surface).
*/ */
export async function GET() { export async function GET() {
const supabase = await createClient() const supabase = await createClient()
@ -22,14 +20,6 @@ export async function GET() {
if (!user) { if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Shallow shape so the client gallery doesn't have to know about // Shallow shape so the client gallery doesn't have to know about
// the full node tree. // the full node tree.
const templates = listFlowTemplates().map((t) => ({ const templates = listFlowTemplates().map((t) => ({

View File

@ -3,7 +3,6 @@ import { createClient } from '@/lib/supabase/server'
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
import { import {
sanitizePhoneForMeta, sanitizePhoneForMeta,
isValidE164, isValidE164,
@ -285,35 +284,28 @@ export async function POST(request: Request) {
.eq('id', conversation_id) .eq('id', conversation_id)
// Pause any active Flow run for this contact — the agent stepping // Pause any active Flow run for this contact — the agent stepping
// in is the strongest "yield, human is here" signal. Gated on the // in is the strongest "yield, human is here" signal. See PR #2
// beta flag to avoid an extra DB write for non-beta accounts; for // plan for why we pause (not end): preserves diagnostic state +
// those, no run can exist anyway. See PR #2 plan for why we pause // lets the agent or the 24h timeout sweep cleanly resolve the
// (not end): preserves diagnostic state + lets the agent or the // run later. For accounts with no active runs the UPDATE matches
// 24h timeout sweep cleanly resolve the run later. // zero rows — cheap and harmless.
try { try {
const { data: profile } = await supabase const { error: pauseErr } = await supabaseAdmin()
.from('profiles') .from('flow_runs')
.select('beta_features') .update({
status: 'paused_by_agent',
ended_at: new Date().toISOString(),
end_reason: 'agent_replied',
})
.eq('user_id', user.id) .eq('user_id', user.id)
.maybeSingle() .eq('contact_id', contact.id)
if (isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { .eq('status', 'active')
const { error: pauseErr } = await supabaseAdmin() if (pauseErr) {
.from('flow_runs') // Best-effort — log + continue. The agent's message already
.update({ // landed at Meta; don't fail the response over a bookkeeping
status: 'paused_by_agent', // miss. Worst case: a stale active run gets caught by the
ended_at: new Date().toISOString(), // stale-run cron sweep within 24h.
end_reason: 'agent_replied', console.error('[flows] pause-on-agent-send failed:', pauseErr.message)
})
.eq('user_id', user.id)
.eq('contact_id', contact.id)
.eq('status', 'active')
if (pauseErr) {
// Best-effort — log + continue. The agent's message already
// landed at Meta; don't fail the response over a bookkeeping
// miss. Worst case: a stale active run gets caught by the
// stale-run cron sweep within 24h.
console.error('[flows] pause-on-agent-send failed:', pauseErr.message)
}
} }
} catch (err) { } catch (err) {
console.error( console.error(

View File

@ -6,7 +6,6 @@ import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
import { runAutomationsForTrigger } from '@/lib/automations/engine' import { runAutomationsForTrigger } from '@/lib/automations/engine'
import { dispatchInboundToFlows } from '@/lib/flows/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine'
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
// Lazy-initialized to avoid build-time crash when env vars are missing // Lazy-initialized to avoid build-time crash when env vars are missing
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -579,15 +578,11 @@ async function processMessage(
// ============================================================ // ============================================================
// Flow runner dispatch. // Flow runner dispatch.
// //
// Gated on the per-account beta flag (see PR #113). For accounts // If the runner consumes the message (it either advanced an active
// that haven't opted in, the runner never executes and the webhook // run or started a new one), we suppress the `new_message_received`
// behaves identically to before — automations fire as today. // + `keyword_match` automation triggers for this inbound. Customer
// // is navigating the bot menu, not sending a fresh trigger word
// For opted-in accounts: if the runner consumes the message (it // that should fork into automations.
// either advanced an active run or started a new one), we suppress
// the `new_message_received` + `keyword_match` automation triggers
// for this inbound. Customer is navigating the bot menu, not
// sending a fresh trigger word that should fork into automations.
// //
// The relationship-level triggers (`new_contact_created`, // The relationship-level triggers (`new_contact_created`,
// `first_inbound_message`) still fire even when consumed — those // `first_inbound_message`) still fire even when consumed — those
@ -595,31 +590,30 @@ async function processMessage(
// //
// Awaited (not fire-and-forget) because we need the `consumed` // Awaited (not fire-and-forget) because we need the `consumed`
// result before deciding whether to dispatch automations. The // result before deciding whether to dispatch automations. The
// runner has its own try/catch and never throws. // runner has its own try/catch and never throws. Accounts with
// no active flows take the runner's early-exit "no_match" path
// basically for free (one indexed SELECT for the active run).
// ============================================================ // ============================================================
let flowConsumed = false const flowResult = await dispatchInboundToFlows({
if (await isProfileFlowsEnabled(userId)) { userId,
const result = await dispatchInboundToFlows({ contactId: contactRecord.id,
userId, conversationId: conversation.id,
contactId: contactRecord.id, message:
conversationId: conversation.id, interactiveReplyId
message: ? {
interactiveReplyId kind: 'interactive_reply',
? { reply_id: interactiveReplyId,
kind: 'interactive_reply', reply_title: contentText ?? '',
reply_id: interactiveReplyId, meta_message_id: message.id,
reply_title: contentText ?? '', }
meta_message_id: message.id, : {
} kind: 'text',
: { text: contentText ?? message.text?.body ?? '',
kind: 'text', meta_message_id: message.id,
text: contentText ?? message.text?.body ?? '', },
meta_message_id: message.id, isFirstInboundMessage,
}, })
isFirstInboundMessage, const flowConsumed = flowResult.consumed
})
flowConsumed = result.consumed
}
// Fire any automations that react to this webhook event. All dispatches // Fire any automations that react to this webhook event. All dispatches
// run here (not earlier) so the contact, conversation, and inbound // run here (not earlier) so the contact, conversation, and inbound
@ -659,36 +653,6 @@ async function processMessage(
} }
} }
/**
* Reads the calling user's `profiles.beta_features` and asks whether
* the Flows beta is enabled for them. Best-effort: a DB failure reads
* as "not enabled" so a transient outage can't accidentally turn the
* feature on for a non-beta account.
*
* Result not cached across webhooks — Meta webhooks are infrequent
* (≪ 1 RPS for a single tenant) and the query is a single indexed
* row lookup. Caching introduces staleness when the owner toggles
* their opt-in via SQL.
*/
async function isProfileFlowsEnabled(userId: string): Promise<boolean> {
try {
const { data } = await supabaseAdmin()
.from('profiles')
.select('beta_features')
.eq('user_id', userId)
.maybeSingle()
return isFlowsEnabled(
data as { beta_features?: string[] | null } | null,
)
} catch (err) {
console.error(
'[flows] isProfileFlowsEnabled lookup failed:',
err instanceof Error ? err.message : err,
)
return false
}
}
async function parseMessageContent( async function parseMessageContent(
message: WhatsAppMessage, message: WhatsAppMessage,
accessToken: string accessToken: string

View File

@ -17,8 +17,6 @@ import {
import { format } from "date-fns"; import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions"; import { MessageReactions } from "./message-reactions";
import { useAuth } from "@/hooks/use-auth";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
interface MessageBubbleProps { interface MessageBubbleProps {
message: Message; message: Message;
@ -119,14 +117,6 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
} }
function MessageContent({ message }: { message: Message }) { function MessageContent({ message }: { message: Message }) {
// Used to gate the interactive-reply bubble style below. The webhook
// only ever inserts `content_type='interactive'` rows when our runner
// sent an interactive message first — and the runner itself is behind
// the same beta gate (forthcoming PRs) — so this gate is belt-and-
// suspenders against any stray row that slipped in via manual SQL,
// a misconfigured import, or a future bug. Non-beta accounts fall
// back to plain text.
const { profile } = useAuth();
switch (message.content_type) { switch (message.content_type) {
case "text": case "text":
return ( return (
@ -224,18 +214,6 @@ function MessageContent({ message }: { message: Message }) {
); );
case "interactive": { case "interactive": {
// Defensive gate — non-beta accounts render the tapped option as
// plain text. See the comment at the top of MessageContent for why
// this can't be reached in normal operation; this branch exists so
// that if a stray interactive row ever appears for a non-beta
// account, they don't see UI for a feature they haven't opted into.
if (!isFlowsEnabled(profile)) {
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"}
</p>
);
}
// Customer tapped a reply button or list row on a message the bot // Customer tapped a reply button or list row on a message the bot
// sent. We show the tapped option's title (already in content_text, // sent. We show the tapped option's title (already in content_text,
// set by parseMessageContent in the webhook) with a small affordance // set by parseMessageContent in the webhook) with a small affordance

View File

@ -6,7 +6,6 @@ import { useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread"; import { useTotalUnread } from "@/hooks/use-total-unread";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import { import {
LayoutDashboard, LayoutDashboard,
MessageSquare, MessageSquare,
@ -38,11 +37,10 @@ interface NavItem {
label: string; label: string;
icon: typeof LayoutDashboard; icon: typeof LayoutDashboard;
/** /**
* If set, only included in the rendered nav when the predicate * When true, the nav row renders a small "Beta" chip after the label.
* returns true. Used to gate beta-only entries (e.g. /flows) on * Purely informational — doesn't affect routing or access.
* `profiles.beta_features`.
*/ */
betaKey?: string; beta?: boolean;
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
@ -52,10 +50,7 @@ const navItems: NavItem[] = [
{ href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/pipelines", label: "Pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio }, { href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap }, { href: "/automations", label: "Automations", icon: Zap },
// Flows is gated on the per-account beta flag; the entry is invisible { href: "/flows", label: "Flows", icon: Workflow, beta: true },
// for accounts that haven't opted in. Server-side route guards make
// this a UX choice, not a security one — the API + pages 404 anyway.
{ href: "/flows", label: "Flows", icon: Workflow, betaKey: "flows" },
]; ];
const bottomNavItems = [ const bottomNavItems = [
@ -150,13 +145,6 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<nav className="flex-1 overflow-y-auto px-3 py-4"> <nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{navItems.map((item) => { {navItems.map((item) => {
// Per-account beta gate. Today only 'flows' uses this;
// extending the navItems list with new betaKeys is a
// single-line change.
if (item.betaKey === "flows" && !isFlowsEnabled(profile)) {
return null;
}
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href)); (item.href !== "/dashboard" && pathname.startsWith(item.href));
@ -178,6 +166,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span> <span className="flex-1">{item.label}</span>
{item.beta && (
<span
aria-label="Beta feature"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
>
Beta
</span>
)}
{showUnreadDot && ( {showUnreadDot && (
<span <span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`} aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}

View File

@ -18,10 +18,9 @@ interface Profile {
avatar_url: string | null; avatar_url: string | null;
role: string | null; role: string | null;
/** /**
* Opted-in beta feature keys for this account (e.g. `'flows'`). * Opted-in beta feature keys for this account. No current feature
* Gates client-side UI surfaces like the sidebar Flows entry; the * reads this — Flows was the last user and went to soft-GA in PR
* server consults the same column for API + webhook guards. See * #134 — but the column survives for future beta gates.
* `src/lib/flows/feature-flag.ts` for the shared predicate.
*/ */
beta_features: string[]; beta_features: string[];
} }
@ -95,7 +94,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but // `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
// narrow defensively in case the column hasn't been migrated yet // narrow defensively in case the column hasn't been migrated yet
// (older deployments running 011 lazily) — `null` reads as no // (older deployments running 011 lazily) — `null` reads as no
// opt-ins, matching what `isFlowsEnabled()` expects. // opt-ins, which is the safe default for any future beta gate.
setProfile({ setProfile({
...data, ...data,
beta_features: data.beta_features ?? [], beta_features: data.beta_features ?? [],

View File

@ -1,76 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isFlowsEnabled,
BETA_FEATURE_FLOWS,
} from "./feature-flag";
describe("isFlowsEnabled", () => {
it("returns false for a null profile", () => {
expect(isFlowsEnabled(null)).toBe(false);
});
it("returns false for an undefined profile", () => {
expect(isFlowsEnabled(undefined)).toBe(false);
});
it("returns false when beta_features is missing", () => {
expect(isFlowsEnabled({})).toBe(false);
});
it("returns false when beta_features is null (pre-migration row)", () => {
expect(isFlowsEnabled({ beta_features: null })).toBe(false);
});
it("returns false for an empty array", () => {
expect(isFlowsEnabled({ beta_features: [] })).toBe(false);
});
it("returns false when only other features are opted in", () => {
expect(
isFlowsEnabled({ beta_features: ["ai_replies", "voice_notes"] }),
).toBe(false);
});
it("returns true when 'flows' is in the array", () => {
expect(isFlowsEnabled({ beta_features: ["flows"] })).toBe(true);
});
it("returns true when 'flows' is in the array alongside others", () => {
expect(
isFlowsEnabled({ beta_features: ["ai_replies", "flows", "voice_notes"] }),
).toBe(true);
});
it("is case-sensitive — only lowercase 'flows' opts in", () => {
// Belt-and-suspenders: a typo'd 'Flows' or 'FLOWS' must NOT count.
// This prevents accidental opt-ins from manual SQL edits.
expect(isFlowsEnabled({ beta_features: ["Flows"] })).toBe(false);
expect(isFlowsEnabled({ beta_features: ["FLOWS"] })).toBe(false);
});
it("guards against non-array values (defensive — DB schema disallows but JSON parsing might)", () => {
// If something ever feeds the helper a malformed profile (e.g. raw
// JSON from a webhook payload that bypassed the type system), we'd
// rather return false than throw mid-request.
expect(
isFlowsEnabled({
// @ts-expect-error — intentional bad input
beta_features: "flows",
}),
).toBe(false);
expect(
isFlowsEnabled({
// @ts-expect-error — intentional bad input
beta_features: { flows: true },
}),
).toBe(false);
});
it("BETA_FEATURE_FLOWS exports the canonical string", () => {
// Pinned so callers writing `beta_features.includes('flows')` and
// callers writing `beta_features.includes(BETA_FEATURE_FLOWS)` can
// never drift apart.
expect(BETA_FEATURE_FLOWS).toBe("flows");
expect(isFlowsEnabled({ beta_features: [BETA_FEATURE_FLOWS] })).toBe(true);
});
});

View File

@ -1,46 +0,0 @@
/**
* Per-account beta gate for the Flows feature.
*
* The Flows feature ships dark — code lives in main, but no user-facing
* surface activates until the owner opts in by adding `'flows'` to their
* `profiles.beta_features` array. The intent is to dogfood the entire
* loop (build → trigger → tap → advance → handoff) on a single account
* before exposing it to teammates or downstream pullers of the OSS.
*
* Why this lives in its own module:
* - Both client (sidebar nav, /flows page gate) and server (webhook
* dispatcher, /api/flows route guards) need to call the same logic.
* Pulling it into a single import target keeps the truth in one
* place — no env vars, no DB query duplication.
* - Once Flows reaches GA, removing the gate is grep-and-delete on a
* single import path.
*
* The function takes a minimal shape (anything with an optional
* `beta_features` array of strings) rather than the full `Profile`
* type so it can be called from places that haven't loaded a full
* profile row — e.g. webhook handlers that only SELECT this one
* column.
*/
/** Stable string keys for beta features. Add new ones here. */
export const BETA_FEATURE_FLOWS = "flows" as const;
export interface BetaFeatureCarrier {
beta_features?: string[] | null;
}
/**
* Returns `true` if the supplied profile (or any compatible shape with
* a `beta_features` array) has opted into the Flows beta.
*
* Safe to call with `null` / `undefined` / a row that predates the
* migration — every absent value reads as "not opted in".
*/
export function isFlowsEnabled(
profile: BetaFeatureCarrier | null | undefined,
): boolean {
if (!profile) return false;
const features = profile.beta_features;
if (!features || !Array.isArray(features)) return false;
return features.includes(BETA_FEATURE_FLOWS);
}

View File

@ -6,10 +6,11 @@ export interface Profile {
avatar_url?: string; avatar_url?: string;
role: string; role: string;
/** /**
* Opted-in beta feature keys for this account. Server reads + UI * Opted-in beta feature keys for this account. The column survives
* gates against this array (see `src/lib/flows/feature-flag.ts`). * for future beta gates; no current feature reads it (Flows was
* Defaults to `[]` for every profile; toggled per-account via a * the last user and went to soft-GA in PR #134). Defaults to `[]`
* direct UPDATE on the `profiles` row. * for every profile; toggled per-account via a direct UPDATE on
* the `profiles` row.
*/ */
beta_features?: string[]; beta_features?: string[];
created_at: string; created_at: string;