Merge pull request #113 from ArnasDon/feat/beta-features-flag

feat(beta): per-account beta_features flag on profiles
This commit is contained in:
Arnas Donauskas 2026-05-22 13:03:22 +03:00 committed by GitHub
commit 981a164cbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 187 additions and 2 deletions

View File

@ -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);
} }

View 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);
});
});

View 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);
}

View File

@ -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;
} }

View 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.