feat(fase-12): section visibility model — feature_flags + section_enabled()

Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:

  * public.known_sections() — IMMUTABLE, returns the canonical 4 sections
    (lists, tasks, notes, search). Adding a section is a fresh migration.
  * public.section_enabled(section, user, collective) — STABLE, returns
    boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
    a single COALESCE so Fase 13 can prepend a server layer as a one-line
    diff. Unknown section keys default TRUE (forward-compat).

No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:47:52 +02:00
parent c1d0dfaee9
commit 59a32f80f7
3 changed files with 273 additions and 0 deletions

View File

@@ -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 24. 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.';