mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(beta): per-account beta_features flag on profiles, gating Flows
Scaffolding for the upcoming WhatsApp Flows feature. The whole Flows
stack (interactive button/list send + parse, server-side runner, no-
code builder, templates) will land across the next ~4 PRs *behind*
this gate so the owner can dogfood the feature on their own account
before exposing it to teammates or downstream pullers of the OSS.
What this lands:
1. `profiles.beta_features TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`
— generic opt-in column, lives on the existing profiles table.
Empty by default for every existing row.
2. `src/lib/flows/feature-flag.ts` — `isFlowsEnabled(profile)`
predicate + `BETA_FEATURE_FLOWS` constant. Single source of
truth for the gate; later PRs import this in every guarded
surface (sidebar nav, /flows page, /api/flows routes, webhook
dispatcher).
3. `Profile.beta_features` added to the shared TS type and the
auth hook's local copy. Auth hook loads the column and
defaults it to `[]` defensively if the DB read returns null.
4. 11 unit tests covering null/undefined/empty/mismatched cases,
including case sensitivity (only lowercase `'flows'` opts in)
and defensive guards against malformed input.
How the owner enables it for their own account, once merged:
UPDATE profiles
SET beta_features = array_append(beta_features, 'flows')
WHERE user_id = '<their-auth-uid>';
How we ship it to everyone, when ready: grep-and-delete the
`isFlowsEnabled()` callsites in one cleanup PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a701d932d2
commit
9c4a46d990
@ -17,6 +17,13 @@ interface Profile {
|
|||||||
email: string;
|
email: string;
|
||||||
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'`).
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
beta_features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@ -50,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("id, full_name, email, avatar_url, role")
|
.select("id, full_name, email, avatar_url, role, beta_features")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@ -64,7 +71,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data) setProfile(data);
|
if (data) {
|
||||||
|
// `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.
|
||||||
|
setProfile({
|
||||||
|
...data,
|
||||||
|
beta_features: data.beta_features ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[AuthProvider] fetchProfile threw:", err);
|
console.error("[AuthProvider] fetchProfile threw:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/lib/flows/feature-flag.test.ts
Normal file
76
src/lib/flows/feature-flag.test.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
src/lib/flows/feature-flag.ts
Normal file
46
src/lib/flows/feature-flag.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@ -5,6 +5,13 @@ export interface Profile {
|
|||||||
email: string;
|
email: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
role: 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.
|
||||||
|
*/
|
||||||
|
beta_features?: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
supabase/migrations/011_profile_beta_features.sql
Normal file
40
supabase/migrations/011_profile_beta_features.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Per-account beta feature flag column on `profiles`.
|
||||||
|
--
|
||||||
|
-- Adds an array of opted-in beta feature keys to each profile row.
|
||||||
|
-- Currently used to gate the Flows feature (`'flows'`); shape is
|
||||||
|
-- generic so subsequent betas (e.g. `'ai_replies'`, `'voice_notes'`)
|
||||||
|
-- can land in this column without another migration.
|
||||||
|
--
|
||||||
|
-- Why a per-account flag rather than a global env var:
|
||||||
|
-- - Self-hosted wacrm instances are multi-user (small teams, shared
|
||||||
|
-- workspaces). A global flag would force every account on the
|
||||||
|
-- instance to opt into a not-yet-stable feature simultaneously.
|
||||||
|
-- - The owner wanted to dogfood the feature on their own account
|
||||||
|
-- before exposing it to teammates. Flipping a column via
|
||||||
|
-- Supabase Studio (`UPDATE profiles SET beta_features = ...
|
||||||
|
-- WHERE user_id = '<theirs>'`) is the lowest-friction toggle.
|
||||||
|
-- - DB-managed flags survive env rotation, deploy-restart timing,
|
||||||
|
-- and (since beta_features is a TEXT[]) extend naturally to
|
||||||
|
-- additional features without further schema work.
|
||||||
|
--
|
||||||
|
-- Default is the empty array, so every existing profile row opts
|
||||||
|
-- out of every beta feature on apply. NOT NULL keeps callers from
|
||||||
|
-- having to defend against `beta_features == null` at every site.
|
||||||
|
--
|
||||||
|
-- Idempotent — safe to run multiple times.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS beta_features TEXT[]
|
||||||
|
NOT NULL
|
||||||
|
DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- No new RLS policy needed: the existing `Users can view own profile` /
|
||||||
|
-- `Users can update own profile` policies (migration 001) already gate
|
||||||
|
-- access to this column. Server-side reads via service_role bypass RLS
|
||||||
|
-- as they do for every other column.
|
||||||
|
--
|
||||||
|
-- No index needed: the column is read on the login codepath (one row
|
||||||
|
-- lookup by primary key / user_id, both already indexed) and very
|
||||||
|
-- rarely written.
|
||||||
Loading…
Reference in New Issue
Block a user