UI side:
* DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
tag each entry with data-section, and BottomTabBar exposes a
data-section-count attribute so the grid is observable from tests.
* "lists" is force-shown unconditionally — the §12.3.4 always-one-
landing rule covers the edge case where every layer says OFF.
* (app)/+layout.svelte adds a $effect that, when the URL matches a
disabled section (/tasks, /notes, /search), goto's /lists and shows
a transient `section_disabled_for_collective` toast.
* Root +layout.svelte exposes the supabase singleton on window.__sb
in dev so the new Playwright spec can patch rows without a parallel
client; dead-code-eliminated in production builds.
DB side:
* Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
public.users + public.collectives + REPLICA IDENTITY FULL on both.
Without this membership the features.ts subscriptions get zero
events and SV-02 (collective toggle → realtime → member's nav
updates) silently fails. Caught while running the spec.
* pgTAP 015 grows from 16 to 20 assertions to cover publication
membership + REPLICA IDENTITY for both new realtime tables.
Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.
Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
SV-01 user toggle → nav reflects → /tasks redirects to /lists
SV-02 admin collective toggle → member sees it disappear in realtime
SV-03 collective ON beats user OFF — per-collective resolution
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
115 lines
6.0 KiB
PL/PgSQL
115 lines
6.0 KiB
PL/PgSQL
-- 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.';
|
||
|
||
-- ── Realtime ────────────────────────────────────────────────────────────────
|
||
-- The client-side `features.ts` store subscribes to `users` (own row) and
|
||
-- `collectives` (active row) so admin/user toggles propagate without a
|
||
-- reload. Without the publication membership those subscriptions receive
|
||
-- zero events — the realtime test (Playwright SV-02) was the catch.
|
||
--
|
||
-- REPLICA IDENTITY FULL is required so the OLD row carries all columns on
|
||
-- DELETE events; we only consume UPDATE here today, but `users` rows can be
|
||
-- deleted by the account-deletion flow (Fase 10.5) and a future Fase 13
|
||
-- "admin removes collective" will delete `collectives`. Filtering both
|
||
-- subscriptions still works correctly on DELETE this way.
|
||
ALTER PUBLICATION supabase_realtime ADD TABLE public.users;
|
||
ALTER PUBLICATION supabase_realtime ADD TABLE public.collectives;
|
||
|
||
ALTER TABLE public.users REPLICA IDENTITY FULL;
|
||
ALTER TABLE public.collectives REPLICA IDENTITY FULL;
|