mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
515cf6427f
@ -5,8 +5,6 @@ import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
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 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
|
||||
* focus purely on editing.
|
||||
*
|
||||
* Beta gate: client-side bounce for the snappy redirect, server-side
|
||||
* 404 on the API as the real security boundary.
|
||||
* Open to every authenticated user — the beta gate that previously
|
||||
* 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() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const { profile, loading: authLoading, profileLoading } = useAuth();
|
||||
|
||||
const [flow, setFlow] = useState<FlowRow | null>(null);
|
||||
const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const allowed = isFlowsEnabled(profile);
|
||||
|
||||
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;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@ -72,9 +60,9 @@ export default function FlowEditorPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allowed, authLoading, profileLoading, params.id, router]);
|
||||
}, [params.id]);
|
||||
|
||||
if (authLoading || profileLoading || (allowed && loading)) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
|
||||
@ -17,8 +17,6 @@ import {
|
||||
import { toast } from "sonner";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
@ -98,7 +96,6 @@ const STATUS_META: Record<
|
||||
export default function FlowRunsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const { profile, loading: authLoading, profileLoading } = useAuth();
|
||||
|
||||
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
|
||||
const [runs, setRuns] = useState<RunRow[]>([]);
|
||||
@ -107,15 +104,7 @@ export default function FlowRunsPage() {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const allowed = isFlowsEnabled(profile);
|
||||
|
||||
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;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@ -148,7 +137,7 @@ export default function FlowRunsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allowed, authLoading, profileLoading, params.id, router]);
|
||||
}, [params.id]);
|
||||
|
||||
function toggle(runId: string) {
|
||||
setExpanded((prev) => {
|
||||
@ -159,7 +148,7 @@ export default function FlowRunsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
if (authLoading || profileLoading || (allowed && loading)) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
|
||||
@ -18,8 +18,6 @@ import {
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -36,10 +34,9 @@ import { cn } from "@/lib/utils";
|
||||
/**
|
||||
* Flows list page.
|
||||
*
|
||||
* Gated client-side on `isFlowsEnabled(profile)` AND server-side by
|
||||
* every /api/flows endpoint returning 404 to non-beta accounts. The
|
||||
* client gate hides the page from non-beta users who land here via
|
||||
* URL-typing; the server gate is the real security boundary.
|
||||
* Open to every authenticated user. Flows is in soft-GA — the "Beta"
|
||||
* chip in the header is the only remaining signal that the surface
|
||||
* is new. The previous per-account beta gate was removed in PR #134.
|
||||
*/
|
||||
|
||||
interface FlowRow {
|
||||
@ -84,7 +81,6 @@ const TEMPLATE_ICONS = {
|
||||
|
||||
export default function FlowsPage() {
|
||||
const router = useRouter();
|
||||
const { profile, loading: authLoading, profileLoading } = useAuth();
|
||||
const [flows, setFlows] = useState<FlowRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@ -92,20 +88,7 @@ export default function FlowsPage() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
|
||||
|
||||
const flowsAccessAllowed = isFlowsEnabled(profile);
|
||||
|
||||
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;
|
||||
(async () => {
|
||||
try {
|
||||
@ -138,7 +121,7 @@ export default function FlowsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authLoading, profileLoading, flowsAccessAllowed, router]);
|
||||
}, []);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
@ -205,7 +188,7 @@ export default function FlowsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || profileLoading || (flowsAccessAllowed && loading)) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<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">
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<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">
|
||||
Build branching, button-driven WhatsApp conversations. Useful for
|
||||
menus, FAQs, and triage before a human steps in.
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
import { validateFlowForActivation } from '@/lib/flows/validate'
|
||||
|
||||
/**
|
||||
@ -31,14 +30,6 @@ export async function POST(
|
||||
if (!user) {
|
||||
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
|
||||
| { status?: 'draft' | 'active' | 'archived' }
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* 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,
|
||||
): Promise<
|
||||
| {
|
||||
@ -33,14 +34,6 @@ async function requireFlowsBetaAndOwnership(
|
||||
if (!user) {
|
||||
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
|
||||
// returns null (404 below).
|
||||
const { data: flow } = await supabase
|
||||
@ -59,7 +52,7 @@ export async function GET(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
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 })
|
||||
const { supabase } = guard
|
||||
|
||||
@ -98,7 +91,7 @@ export async function PUT(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
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 })
|
||||
|
||||
const body = (await request.json().catch(() => null)) as PutBody | null
|
||||
@ -184,7 +177,7 @@ export async function DELETE(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
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 })
|
||||
|
||||
// CASCADE on flow_nodes / flow_runs / flow_run_events handles the
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
|
||||
/**
|
||||
* GET /api/flows/[id]/runs
|
||||
@ -30,14 +29,6 @@ export async function GET(
|
||||
if (!user) {
|
||||
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
|
||||
// the run query — gives us a clean 404 instead of empty array.
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
import { getFlowTemplate } from '@/lib/flows/templates'
|
||||
|
||||
/**
|
||||
* GET /api/flows — list the caller's flows.
|
||||
* POST /api/flows — create a new (draft) flow.
|
||||
*
|
||||
* Both endpoints gate on the per-account Flows beta flag. Non-beta
|
||||
* accounts get a 404 so the UI / a curious user can't discover the
|
||||
* surface ahead of GA.
|
||||
* Available to every authenticated user. The previous per-account
|
||||
* beta gate was removed when Flows went to soft-GA; the UI still
|
||||
* 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: false; status: number; body: { error: string } }
|
||||
> {
|
||||
@ -24,23 +24,11 @@ async function requireFlowsBeta(): Promise<
|
||||
if (!user) {
|
||||
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 }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const guard = await requireFlowsBeta()
|
||||
const guard = await requireUser()
|
||||
if (!guard.ok) {
|
||||
return NextResponse.json(guard.body, { status: guard.status })
|
||||
}
|
||||
@ -57,7 +45,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const guard = await requireFlowsBeta()
|
||||
const guard = await requireUser()
|
||||
if (!guard.ok) {
|
||||
return NextResponse.json(guard.body, { status: guard.status })
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
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
|
||||
* are fetched only on actual clone via POST /api/flows.
|
||||
*
|
||||
* 404 to non-beta accounts (matches the rest of the /api/flows
|
||||
* surface).
|
||||
* Available to any signed-in user. Flows is in soft-GA.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@ -22,14 +20,6 @@ export async function GET() {
|
||||
if (!user) {
|
||||
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
|
||||
// the full node tree.
|
||||
const templates = listFlowTemplates().map((t) => ({
|
||||
|
||||
@ -3,7 +3,6 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import { isFlowsEnabled } from '@/lib/flows/feature-flag'
|
||||
import {
|
||||
sanitizePhoneForMeta,
|
||||
isValidE164,
|
||||
@ -285,18 +284,12 @@ export async function POST(request: Request) {
|
||||
.eq('id', conversation_id)
|
||||
|
||||
// Pause any active Flow run for this contact — the agent stepping
|
||||
// in is the strongest "yield, human is here" signal. Gated on the
|
||||
// beta flag to avoid an extra DB write for non-beta accounts; for
|
||||
// those, no run can exist anyway. See PR #2 plan for why we pause
|
||||
// (not end): preserves diagnostic state + lets the agent or the
|
||||
// 24h timeout sweep cleanly resolve the run later.
|
||||
// in is the strongest "yield, human is here" signal. See PR #2
|
||||
// plan for why we pause (not end): preserves diagnostic state +
|
||||
// lets the agent or the 24h timeout sweep cleanly resolve the
|
||||
// run later. For accounts with no active runs the UPDATE matches
|
||||
// zero rows — cheap and harmless.
|
||||
try {
|
||||
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)) {
|
||||
const { error: pauseErr } = await supabaseAdmin()
|
||||
.from('flow_runs')
|
||||
.update({
|
||||
@ -314,7 +307,6 @@ export async function POST(request: Request) {
|
||||
// stale-run cron sweep within 24h.
|
||||
console.error('[flows] pause-on-agent-send failed:', pauseErr.message)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[flows] pause-on-agent-send threw:',
|
||||
|
||||
@ -6,7 +6,6 @@ import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
||||
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
|
||||
import { runAutomationsForTrigger } from '@/lib/automations/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
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -579,15 +578,11 @@ async function processMessage(
|
||||
// ============================================================
|
||||
// Flow runner dispatch.
|
||||
//
|
||||
// Gated on the per-account beta flag (see PR #113). For accounts
|
||||
// that haven't opted in, the runner never executes and the webhook
|
||||
// behaves identically to before — automations fire as today.
|
||||
//
|
||||
// For opted-in accounts: if the runner consumes the message (it
|
||||
// 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.
|
||||
// If the runner consumes the message (it 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`,
|
||||
// `first_inbound_message`) still fire even when consumed — those
|
||||
@ -595,11 +590,11 @@ async function processMessage(
|
||||
//
|
||||
// Awaited (not fire-and-forget) because we need the `consumed`
|
||||
// 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
|
||||
if (await isProfileFlowsEnabled(userId)) {
|
||||
const result = await dispatchInboundToFlows({
|
||||
const flowResult = await dispatchInboundToFlows({
|
||||
userId,
|
||||
contactId: contactRecord.id,
|
||||
conversationId: conversation.id,
|
||||
@ -618,8 +613,7 @@ async function processMessage(
|
||||
},
|
||||
isFirstInboundMessage,
|
||||
})
|
||||
flowConsumed = result.consumed
|
||||
}
|
||||
const flowConsumed = flowResult.consumed
|
||||
|
||||
// Fire any automations that react to this webhook event. All dispatches
|
||||
// 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(
|
||||
message: WhatsAppMessage,
|
||||
accessToken: string
|
||||
|
||||
@ -17,8 +17,6 @@ import {
|
||||
import { format } from "date-fns";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
import { MessageReactions } from "./message-reactions";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
@ -119,14 +117,6 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
|
||||
}
|
||||
|
||||
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) {
|
||||
case "text":
|
||||
return (
|
||||
@ -224,18 +214,6 @@ function MessageContent({ message }: { message: Message }) {
|
||||
);
|
||||
|
||||
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
|
||||
// sent. We show the tapped option's title (already in content_text,
|
||||
// set by parseMessageContent in the webhook) with a small affordance
|
||||
|
||||
@ -6,7 +6,6 @@ import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTotalUnread } from "@/hooks/use-total-unread";
|
||||
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
@ -38,11 +37,10 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
/**
|
||||
* If set, only included in the rendered nav when the predicate
|
||||
* returns true. Used to gate beta-only entries (e.g. /flows) on
|
||||
* `profiles.beta_features`.
|
||||
* When true, the nav row renders a small "Beta" chip after the label.
|
||||
* Purely informational — doesn't affect routing or access.
|
||||
*/
|
||||
betaKey?: string;
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@ -52,10 +50,7 @@ const navItems: NavItem[] = [
|
||||
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
||||
{ href: "/automations", label: "Automations", icon: Zap },
|
||||
// Flows is gated on the per-account beta flag; the entry is invisible
|
||||
// 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" },
|
||||
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
|
||||
];
|
||||
|
||||
const bottomNavItems = [
|
||||
@ -150,13 +145,6 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{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 =
|
||||
pathname === 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" />
|
||||
<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 && (
|
||||
<span
|
||||
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
||||
|
||||
@ -18,10 +18,9 @@ interface Profile {
|
||||
avatar_url: string | null;
|
||||
role: string | null;
|
||||
/**
|
||||
* Opted-in beta feature keys for this account (e.g. `'flows'`).
|
||||
* Gates client-side UI surfaces like the sidebar Flows entry; the
|
||||
* server consults the same column for API + webhook guards. See
|
||||
* `src/lib/flows/feature-flag.ts` for the shared predicate.
|
||||
* Opted-in beta feature keys for this account. No current feature
|
||||
* reads this — Flows was the last user and went to soft-GA in PR
|
||||
* #134 — but the column survives for future beta gates.
|
||||
*/
|
||||
beta_features: string[];
|
||||
}
|
||||
@ -95,7 +94,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
|
||||
// narrow defensively in case the column hasn't been migrated yet
|
||||
// (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({
|
||||
...data,
|
||||
beta_features: data.beta_features ?? [],
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
@ -6,10 +6,11 @@ export interface Profile {
|
||||
avatar_url?: string;
|
||||
role: string;
|
||||
/**
|
||||
* Opted-in beta feature keys for this account. Server reads + UI
|
||||
* gates against this array (see `src/lib/flows/feature-flag.ts`).
|
||||
* Defaults to `[]` for every profile; toggled per-account via a
|
||||
* direct UPDATE on the `profiles` row.
|
||||
* Opted-in beta feature keys for this account. The column survives
|
||||
* for future beta gates; no current feature reads it (Flows was
|
||||
* the last user and went to soft-GA in PR #134). Defaults to `[]`
|
||||
* for every profile; toggled per-account via a direct UPDATE on
|
||||
* the `profiles` row.
|
||||
*/
|
||||
beta_features?: string[];
|
||||
created_at: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user