diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx index b187a93..b04970e 100644 --- a/src/hooks/use-auth.tsx +++ b/src/hooks/use-auth.tsx @@ -17,6 +17,13 @@ interface Profile { email: string; 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. + */ + beta_features: string[]; } interface AuthContextValue { @@ -50,7 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const { data, error } = await supabase .from("profiles") - .select("id, full_name, email, avatar_url, role") + .select("id, full_name, email, avatar_url, role, beta_features") .eq("user_id", userId) .maybeSingle(); @@ -64,7 +71,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { 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) { console.error("[AuthProvider] fetchProfile threw:", err); } diff --git a/src/lib/flows/feature-flag.test.ts b/src/lib/flows/feature-flag.test.ts new file mode 100644 index 0000000..23099b5 --- /dev/null +++ b/src/lib/flows/feature-flag.test.ts @@ -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); + }); +}); diff --git a/src/lib/flows/feature-flag.ts b/src/lib/flows/feature-flag.ts new file mode 100644 index 0000000..70a7ed0 --- /dev/null +++ b/src/lib/flows/feature-flag.ts @@ -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); +} diff --git a/src/types/index.ts b/src/types/index.ts index 8d00e97..1cd1df3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,13 @@ export interface Profile { email: string; 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. + */ + beta_features?: string[]; created_at: string; } diff --git a/supabase/migrations/011_profile_beta_features.sql b/supabase/migrations/011_profile_beta_features.sql new file mode 100644 index 0000000..b81df91 --- /dev/null +++ b/supabase/migrations/011_profile_beta_features.sql @@ -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 = ''`) 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.