diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 5a0ff80..04b5c1b 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -33,6 +33,7 @@ export interface Database { avatar_emoji: string | null; avatar_url: string | null; theme: ThemeMode; + feature_flags: Json; created_at: string; updated_at: string; }; @@ -45,6 +46,7 @@ export interface Database { avatar_emoji?: string | null; avatar_url?: string | null; theme?: ThemeMode; + feature_flags?: Json; created_at?: string; updated_at?: string; }; @@ -57,6 +59,7 @@ export interface Database { avatar_emoji?: string | null; avatar_url?: string | null; theme?: ThemeMode; + feature_flags?: Json; created_at?: string; updated_at?: string; }; @@ -68,6 +71,7 @@ export interface Database { name: string; emoji: string; created_by: string; + feature_flags: Json; created_at: string; }; Insert: { @@ -75,6 +79,7 @@ export interface Database { name: string; emoji?: string; created_by: string; + feature_flags?: Json; created_at?: string; }; Update: { @@ -82,6 +87,7 @@ export interface Database { name?: string; emoji?: string; created_by?: string; + feature_flags?: Json; created_at?: string; }; Relationships: [ @@ -485,6 +491,14 @@ export interface Database { Args: { p_item_id: string }; Returns: string; }; + known_sections: { + Args: Record; + Returns: string[]; + }; + section_enabled: { + Args: { p_section: string; p_user: string; p_collective: string }; + Returns: boolean; + }; }; Enums: { language_code: LanguageCode; diff --git a/supabase/migrations/023_section_visibility.sql b/supabase/migrations/023_section_visibility.sql new file mode 100644 index 0000000..7acae7b --- /dev/null +++ b/supabase/migrations/023_section_visibility.sql @@ -0,0 +1,97 @@ +-- Migration 023: section visibility / feature flags (Fase 12) +-- +-- Lets each user hide top-level sections they don't use, and lets admins of a +-- collective hide them for every member. The control surface is intentionally +-- typed-rigid via `public.known_sections()` instead of being a free-form bag +-- of strings — adding a new section requires a migration (which is the same +-- diff that adds the section's routes anyway, so no extra friction). +-- +-- ── Precedence ─────────────────────────────────────────────────────────────── +-- Effective layers, MOST restrictive wins: +-- 1. (Fase 13) server — server_settings.default_sections jsonb +-- 2. collective — collectives.feature_flags jsonb (admin override) +-- 3. user — users.feature_flags jsonb (per-user override) +-- 4. default — true (ON) +-- Fase 12 implements layers 2–4. Fase 13 will extend `public.section_enabled()` +-- with a server layer above layer 2. The function body below is structured as +-- a single `coalesce(...)` so that adding the server layer is a one-line diff +-- (prepend the new SELECT to the coalesce) — no callers change shape. +-- +-- ── Data shape ─────────────────────────────────────────────────────────────── +-- `feature_flags jsonb` (object) where each key is a section name (matching +-- `known_sections()`) and the value is the boolean override. Missing key → +-- "no opinion" → defer to the next layer. Default for both columns is `{}` +-- so existing rows opt in to nothing and behave exactly as before. +-- +-- RLS: write-paths on `users.feature_flags` and `collectives.feature_flags` +-- piggyback on the existing `users_update_own` and `collectives_update` +-- policies (migration 003). No new policy is required — the JSONB column is +-- covered by the row-level UPDATE policy automatically. + +ALTER TABLE public.users + ADD COLUMN feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE public.collectives + ADD COLUMN feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN public.users.feature_flags IS + 'Per-user section visibility overrides. Keys must be in public.known_sections().'; +COMMENT ON COLUMN public.collectives.feature_flags IS + 'Per-collective section visibility overrides set by an admin. Outranks the user layer.'; + +-- ── known_sections() ──────────────────────────────────────────────────────── +-- IMMUTABLE: the result is a compile-time literal of the source rows in this +-- migration. Adding/removing a section is a fresh migration, not a runtime +-- change, so it's safe to inline as IMMUTABLE. +CREATE OR REPLACE FUNCTION public.known_sections() +RETURNS text[] +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT ARRAY['lists', 'tasks', 'notes', 'search']::text[] +$$; + +COMMENT ON FUNCTION public.known_sections() IS + 'Canonical list of top-level sections that participate in feature_flags. ' + 'Each new section requires a migration that updates this function + a ' + 'toggle row in /settings + /collective/manage.'; + +-- ── section_enabled() ─────────────────────────────────────────────────────── +-- Resolves the effective ON/OFF state for one section, in one statement, with +-- the layered precedence documented at the top of this file. +-- +-- The `coalesce(NULL, NULL, default)` pattern relies on `(jsonb ->> key)` +-- returning NULL when the key is missing — so a layer with "no opinion" +-- transparently falls through. An unknown section key returns NULL from every +-- layer and the trailing `true` wins, matching the documented default-ON +-- behaviour (a new feature is visible by default until someone opts out). +CREATE OR REPLACE FUNCTION public.section_enabled( + p_section text, + p_user uuid, + p_collective uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE( + -- Fase 13 will prepend a server layer here: + -- (SELECT (value ->> p_section)::boolean + -- FROM public.server_settings + -- WHERE key = 'default_sections'), + (SELECT (feature_flags ->> p_section)::boolean + FROM public.collectives + WHERE id = p_collective), + (SELECT (feature_flags ->> p_section)::boolean + FROM public.users + WHERE id = p_user), + true + ); +$$; + +COMMENT ON FUNCTION public.section_enabled(text, uuid, uuid) IS + 'Effective ON/OFF of a top-level section for (user, collective). ' + 'Precedence: collective override > user override > default true. ' + 'Fase 13 will prepend a server-settings layer above the collective.'; diff --git a/supabase/tests/015_section_visibility.sql b/supabase/tests/015_section_visibility.sql new file mode 100644 index 0000000..c7e27f2 --- /dev/null +++ b/supabase/tests/015_section_visibility.sql @@ -0,0 +1,162 @@ +-- pgTAP: section visibility (feature_flags) — Fase 12.1 +-- Run with: psql -U postgres -d postgres -f supabase/tests/015_section_visibility.sql +-- +-- Verifies: +-- * `users.feature_flags` and `collectives.feature_flags` JSONB columns exist +-- with NOT NULL + default '{}'. +-- * `public.known_sections()` is IMMUTABLE and returns the canonical 4 sections. +-- * `public.section_enabled(section, user, collective)` is STABLE, returns +-- boolean, and applies the documented precedence: +-- collective override > user override > default ON. +-- The forward-compat server layer (Fase 13) adds another layer ABOVE the +-- collective one; nothing here should make that diff awkward. +-- +-- These pgTAP assertions run as postgres which bypasses RLS — the RLS write +-- path for `users.feature_flags` / `collectives.feature_flags` is covered by +-- the Vitest integration suite (tests/section-visibility.test.ts). + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(16); + +-- ── Schema invariants ─────────────────────────────────────────────────────── + +SELECT has_column( + 'public', 'users', 'feature_flags', + 'SV-T01: public.users has feature_flags column' +); + +SELECT col_type_is( + 'public', 'users', 'feature_flags', 'jsonb', + 'SV-T02: public.users.feature_flags is jsonb' +); + +SELECT col_not_null( + 'public', 'users', 'feature_flags', + 'SV-T03: public.users.feature_flags is NOT NULL' +); + +SELECT has_column( + 'public', 'collectives', 'feature_flags', + 'SV-T04: public.collectives has feature_flags column' +); + +SELECT col_type_is( + 'public', 'collectives', 'feature_flags', 'jsonb', + 'SV-T05: public.collectives.feature_flags is jsonb' +); + +SELECT col_not_null( + 'public', 'collectives', 'feature_flags', + 'SV-T06: public.collectives.feature_flags is NOT NULL' +); + +-- ── known_sections() ──────────────────────────────────────────────────────── + +SELECT has_function( + 'public', 'known_sections', + 'SV-T07: public.known_sections() exists' +); + +SELECT volatility_is( + 'public', 'known_sections', ARRAY[]::text[], 'immutable', + 'SV-T08: known_sections() is IMMUTABLE' +); + +SELECT results_eq( + $$ SELECT public.known_sections() $$, + $$ VALUES (ARRAY['lists','tasks','notes','search']) $$, + 'SV-T09: known_sections() returns the canonical 4 sections in order' +); + +-- ── section_enabled() ─────────────────────────────────────────────────────── + +SELECT has_function( + 'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], + 'SV-T10: public.section_enabled(text, uuid, uuid) exists' +); + +SELECT function_returns( + 'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], 'boolean', + 'SV-T11: section_enabled() returns boolean' +); + +SELECT volatility_is( + 'public', 'section_enabled', ARRAY['text', 'uuid', 'uuid'], 'stable', + 'SV-T12: section_enabled() is STABLE (planner-cacheable in a statement)' +); + +-- ── Precedence semantics ──────────────────────────────────────────────────── +-- Uses Ana (seed admin) + the seed collective. Reset both flag columns to {} +-- at the top of each assertion so the previous one doesn't leak. + +UPDATE public.users + SET feature_flags = '{}'::jsonb + WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.collectives + SET feature_flags = '{}'::jsonb + WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +-- SV-T13: default ON — empty flags on both rows → tasks enabled. +SELECT is( + public.section_enabled( + 'tasks', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + true, + 'SV-T13: with empty flag JSON on both rows the section defaults to ON' +); + +-- SV-T14: user override OFF wins when collective has no opinion. +UPDATE public.users + SET feature_flags = '{"tasks": false}'::jsonb + WHERE id = '11111111-1111-1111-1111-111111111111'; +SELECT is( + public.section_enabled( + 'tasks', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + false, + 'SV-T14: user OFF + collective absent → OFF' +); + +-- SV-T15: collective override OFF wins even when user explicitly enables. +UPDATE public.users + SET feature_flags = '{"tasks": true}'::jsonb + WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.collectives + SET feature_flags = '{"tasks": false}'::jsonb + WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +SELECT is( + public.section_enabled( + 'tasks', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + false, + 'SV-T15: collective OFF beats user ON (collective is the higher layer)' +); + +-- SV-T16: unknown sections default ON (forward-compat — new code can call +-- section_enabled() before a migration adds the key to known_sections()). +UPDATE public.users + SET feature_flags = '{}'::jsonb + WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.collectives + SET feature_flags = '{}'::jsonb + WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +SELECT is( + public.section_enabled( + 'foo-unknown-section', + '11111111-1111-1111-1111-111111111111', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + ), + true, + 'SV-T16: an unknown section key returns true (default ON, no migration race)' +); + +SELECT * FROM finish(); +ROLLBACK;