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