From 59c425f6f6ef1dfb82d14c4169f4b433af85f964 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 03:53:54 +0200 Subject: [PATCH] feat(fase-12): features store + integration coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client mirror of section_enabled(): * SectionKey / FeatureFlags types + SECTION_KEYS constant in @colectivo/types so the SQL and TS sides share the same vocabulary. * apps/web/src/lib/stores/features.ts: - currentUserFeatures writable, loaded by root layout - enabledSections derived map { section → boolean } - enabledSectionList ordered SectionKey[] visible to UI; forces 'lists' if every layer says OFF - setUserFeature per-user toggle (optimistic + rollback) - setCollectiveFeature admin toggle (rolls back on RLS deny) - realtime subscriptions one channel for the user's row, one for the active collective; rebuilt whenever those targets change Wired into the root layout: loadCurrentUserFeatures runs from inside the existing setTimeout-deferred onAuthStateChange callback (the documented gotcha), and currentCollective.subscribe drives subscribeCollectiveFeatures so collective switches re-target the realtime filter without an extra auth event. loadUserCollectives + every other place that fabricates a Collective object now carries feature_flags (defaulting to {} for freshly created collectives) so the Collective interface stays sound. Integration spec (6 tests, SV-01..SV-06) covers default-ON, user-only, collective-beats-user, RLS writes (user_update_own + collectives_update), and per-collective isolation for a user in two collectives. Co-Authored-By: Claude Opus 4.7 --- .../components/CreateCollectiveModal.svelte | 6 +- apps/web/src/lib/stores/collective.ts | 2 + apps/web/src/lib/stores/features.ts | 282 ++++++++++++++++++ .../(app)/collective/manage/+page.svelte | 32 +- apps/web/src/routes/+layout.svelte | 55 +++- apps/web/src/routes/onboarding/+page.svelte | 9 +- .../tests/section-visibility.test.ts | 233 +++++++++++++++ packages/types/src/domain.ts | 16 + 8 files changed, 619 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/lib/stores/features.ts create mode 100644 packages/test-utils/tests/section-visibility.test.ts diff --git a/apps/web/src/lib/components/CreateCollectiveModal.svelte b/apps/web/src/lib/components/CreateCollectiveModal.svelte index e2b656a..26ebf68 100644 --- a/apps/web/src/lib/components/CreateCollectiveModal.svelte +++ b/apps/web/src/lib/components/CreateCollectiveModal.svelte @@ -30,7 +30,11 @@ error = rpcErr?.message ?? 'Failed to create collective.'; return; } - const created = data as { id: string; name: string; emoji: string; created_at: string }; + const created = { + ...(data as { id: string; name: string; emoji: string; created_at: string }), + // New collective starts with no flags — every section ON by default. + feature_flags: {} as import('@colectivo/types').FeatureFlags + }; userCollectives.update((list) => [...list, created]); currentCollective.set(created); localStorage.setItem('activeCollectiveId', created.id); diff --git a/apps/web/src/lib/stores/collective.ts b/apps/web/src/lib/stores/collective.ts index 031be77..16aa225 100644 --- a/apps/web/src/lib/stores/collective.ts +++ b/apps/web/src/lib/stores/collective.ts @@ -1,10 +1,12 @@ import { writable } from 'svelte/store'; +import type { FeatureFlags } from '@colectivo/types'; export interface Collective { id: string; name: string; emoji: string; created_at: string; + feature_flags: FeatureFlags; } export interface CollectiveMember { diff --git a/apps/web/src/lib/stores/features.ts b/apps/web/src/lib/stores/features.ts new file mode 100644 index 0000000..a4af3e3 --- /dev/null +++ b/apps/web/src/lib/stores/features.ts @@ -0,0 +1,282 @@ +/** + * Section visibility / feature flags — client-side mirror of + * `public.section_enabled()` (Fase 12). + * + * Why mirror instead of round-trip per render: every navigation tile, redirect + * guard, and tab-bar grid recompute would otherwise issue one PostgREST call + * per section per render — three orders of magnitude too noisy. The server + * function stays the source of truth (RLS-guarded writes still go through it + * indirectly via the `users.feature_flags` / `collectives.feature_flags` + * columns); this store just resolves the same precedence locally. + * + * Precedence (mirrors migration 023): + * collective override > user override > default true + * Fase 13 will add a server layer on top; this file plus the SQL function + * are the two places to touch. + * + * Persistence layer: + * - `currentUserFeatures` — { feature_flags: FeatureFlags } for the + * logged-in user, populated by root `+layout.svelte`. + * - `currentCollective` — already exposes `feature_flags` after the + * `loadUserCollectives` extension in this fase. + * + * Realtime: this module subscribes to the relevant rows so an admin in + * another tab can flip a collective flag and the current tab reacts + * within a second. The subscriptions are keyed off the active user / active + * collective and torn down when those change (one channel each at a time). + */ +import { writable, derived, get } from 'svelte/store'; +import type { RealtimeChannel } from '@supabase/supabase-js'; +import type { FeatureFlags, SectionKey } from '@colectivo/types'; +import { SECTION_KEYS } from '@colectivo/types'; +import { getSupabase } from '$lib/supabase'; +import { currentUser } from '$lib/stores/auth'; +import { currentCollective } from '$lib/stores/collective'; + +// ── Stores ────────────────────────────────────────────────────────────────── + +/** + * The logged-in user's feature_flags row. Populated by root layout's + * `loadCurrentUserFeatures()` after sign-in / token-refresh, and kept fresh + * by the realtime subscription below. `null` until the first load resolves. + */ +export const currentUserFeatures = writable<{ feature_flags: FeatureFlags } | null>(null); + +/** + * Derived map { section → boolean } applying the same precedence as + * `public.section_enabled()`. Layers, MOST restrictive wins: + * 1. collective.feature_flags[section] (admin override) + * 2. user.feature_flags[section] (per-user override) + * 3. default true + * + * Unknown section keys are not part of SECTION_KEYS so they are never queried + * here — that path is reserved for SQL-side callers (migrations 13+). + */ +export const enabledSections = derived( + [currentUserFeatures, currentCollective], + ([$user, $collective]) => { + const userFlags = ($user?.feature_flags ?? {}) as FeatureFlags; + const collectiveFlags = ($collective?.feature_flags ?? {}) as FeatureFlags; + const out: Record = {} as Record; + for (const key of SECTION_KEYS) { + const collectiveVal = collectiveFlags[key]; + const userVal = userFlags[key]; + out[key] = collectiveVal ?? userVal ?? true; + } + return out; + } +); + +/** + * Lower-cardinality derived view: just the SectionKeys that are enabled, + * preserved in the SECTION_KEYS order. Cheap to iterate in nav lists. + * + * Edge case: if every layer answers OFF for every section, force "lists" ON + * (the user must always have somewhere to land). This is the only client-side + * hard override and matches plan §12.3.4. + */ +export const enabledSectionList = derived(enabledSections, ($flags) => { + const visible = SECTION_KEYS.filter((k) => $flags[k]); + if (visible.length === 0) return ['lists' as SectionKey]; + return visible; +}); + +// ── Mutations ─────────────────────────────────────────────────────────────── + +/** + * Write the calling user's per-section flag. Patches the existing JSONB row + * so we don't clobber other sections. + */ +export async function setUserFeature(section: SectionKey, value: boolean): Promise { + const user = get(currentUser); + if (!user) return; + + const current = get(currentUserFeatures); + const next: FeatureFlags = { ...(current?.feature_flags ?? {}), [section]: value }; + + // Optimistic local update — realtime echo will deduplicate. + currentUserFeatures.set({ feature_flags: next }); + + const { error } = await getSupabase() + .from('users') + .update({ feature_flags: next }) + .eq('id', user.id); + + if (error) { + // Roll back on failure so the toggle reflects ground truth. + currentUserFeatures.set(current); + throw error; + } +} + +/** + * Admin-only: write the active collective's per-section flag. Patches the + * existing JSONB row. RLS (collectives_update) returns 0 rows updated for + * non-admins; we surface that as a thrown Error so the UI can show a hint + * instead of silently no-op-ing. + */ +export async function setCollectiveFeature( + section: SectionKey, + value: boolean +): Promise { + const collective = get(currentCollective); + if (!collective) return; + + const currentFlags = (collective.feature_flags ?? {}) as FeatureFlags; + const next: FeatureFlags = { ...currentFlags, [section]: value }; + + // Optimistic local update for the active collective in both stores. + const optimistic = { ...collective, feature_flags: next }; + currentCollective.set(optimistic); + + const { data, error } = await getSupabase() + .from('collectives') + .update({ feature_flags: next }) + .eq('id', collective.id) + .select('id, feature_flags'); + + if (error) { + currentCollective.set(collective); + throw error; + } + if (!data || data.length === 0) { + // RLS denied — restore. + currentCollective.set(collective); + throw new Error('forbidden'); + } +} + +// ── Realtime ──────────────────────────────────────────────────────────────── + +let userChannel: RealtimeChannel | null = null; +let userChannelUserId: string | null = null; +let collectiveChannel: RealtimeChannel | null = null; +let collectiveChannelId: string | null = null; + +/** + * Initial load + realtime subscription for the logged-in user's row. Called + * by root `+layout.svelte` after the auth callback resolves a session. + * + * Per CLAUDE.md gotcha "loadUserCollectives ... inside onAuthStateChange must + * be deferred" — callers MUST already have done the setTimeout(..., 0) + * unwrap. We do not re-defer here. + */ +export async function loadCurrentUserFeatures(userId: string): Promise { + const supabase = getSupabase(); + const { data } = await supabase + .from('users') + .select('feature_flags') + .eq('id', userId) + .single(); + + currentUserFeatures.set({ + feature_flags: ((data?.feature_flags ?? {}) as FeatureFlags) || {} + }); + + if (userChannelUserId !== userId) { + await teardownUserChannel(); + userChannelUserId = userId; + const channel = supabase.channel(`features:user:${userId}`); + channel.on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'users', + filter: `id=eq.${userId}` + } as never, + ((payload: { new: { feature_flags?: FeatureFlags | null } }) => { + const next = (payload.new?.feature_flags ?? {}) as FeatureFlags; + currentUserFeatures.set({ feature_flags: next }); + }) as never + ); + await subscribeWithTimeout(channel, `features:user:${userId}`); + userChannel = channel; + } +} + +/** + * Realtime subscription for the active collective's row. Called by root + * layout whenever `$currentCollective.id` changes. + */ +export async function subscribeCollectiveFeatures(collectiveId: string): Promise { + const supabase = getSupabase(); + if (collectiveChannelId === collectiveId) return; + await teardownCollectiveChannel(); + collectiveChannelId = collectiveId; + const channel = supabase.channel(`features:collective:${collectiveId}`); + channel.on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'collectives', + filter: `id=eq.${collectiveId}` + } as never, + ((payload: { + new: { id: string; name: string; emoji: string; created_at: string; feature_flags?: FeatureFlags | null }; + }) => { + const incoming = payload.new; + const flags = (incoming.feature_flags ?? {}) as FeatureFlags; + // Patch the active-collective store if the event refers to it. + const active = get(currentCollective); + if (active && active.id === incoming.id) { + currentCollective.set({ + ...active, + name: incoming.name, + emoji: incoming.emoji, + feature_flags: flags + }); + } + }) as never + ); + await subscribeWithTimeout(channel, `features:collective:${collectiveId}`); + collectiveChannel = channel; +} + +export async function teardownFeatureSubscriptions(): Promise { + await teardownUserChannel(); + await teardownCollectiveChannel(); + currentUserFeatures.set(null); +} + +async function teardownUserChannel(): Promise { + if (!userChannel) return; + const supabase = getSupabase(); + try { + await userChannel.unsubscribe(); + await supabase.removeChannel(userChannel); + } catch { + /* idempotent */ + } + userChannel = null; + userChannelUserId = null; +} + +async function teardownCollectiveChannel(): Promise { + if (!collectiveChannel) return; + const supabase = getSupabase(); + try { + await collectiveChannel.unsubscribe(); + await supabase.removeChannel(collectiveChannel); + } catch { + /* idempotent */ + } + collectiveChannel = null; + collectiveChannelId = null; +} + +async function subscribeWithTimeout(channel: RealtimeChannel, label: string): Promise { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} subscribe timeout`)), 10_000); + channel.subscribe((status) => { + if (status === 'SUBSCRIBED') { + clearTimeout(timer); + resolve(); + } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { + clearTimeout(timer); + reject(new Error(`${label} subscribe failed: ${status}`)); + } + }); + }); +} diff --git a/apps/web/src/routes/(app)/collective/manage/+page.svelte b/apps/web/src/routes/(app)/collective/manage/+page.svelte index b2f7012..23075e0 100644 --- a/apps/web/src/routes/(app)/collective/manage/+page.svelte +++ b/apps/web/src/routes/(app)/collective/manage/+page.svelte @@ -171,14 +171,22 @@ .from('collectives') .update({ name: trimmed }) .eq('id', $currentCollective.id) - .select('id, name, emoji, created_at') + .select('id, name, emoji, created_at, feature_flags') .single(); nameSaving = false; if (updErr || !data) { error = updErr?.message ?? 'Failed to save name.'; return; } - applyCollectivePatch(data as { id: string; name: string; emoji: string; created_at: string }); + applyCollectivePatch( + data as { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags: import('@colectivo/types').FeatureFlags; + } + ); nameSaved = true; setTimeout(() => (nameSaved = false), 1500); } @@ -200,16 +208,30 @@ .from('collectives') .update({ emoji }) .eq('id', $currentCollective.id) - .select('id, name, emoji, created_at') + .select('id, name, emoji, created_at, feature_flags') .single(); if (updErr || !data) { error = updErr?.message ?? 'Failed to save emoji.'; return; } - applyCollectivePatch(data as { id: string; name: string; emoji: string; created_at: string }); + applyCollectivePatch( + data as { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags: import('@colectivo/types').FeatureFlags; + } + ); } - function applyCollectivePatch(c: { id: string; name: string; emoji: string; created_at: string }) { + function applyCollectivePatch(c: { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags: import('@colectivo/types').FeatureFlags; + }) { currentCollective.set(c); userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it))); } diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index f284bb9..ef8c120 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -6,6 +6,12 @@ import { getSupabase } from '$lib/supabase'; import { currentUser, authLoading } from '$lib/stores/auth'; import { userCollectives, currentCollective } from '$lib/stores/collective'; + import { + loadCurrentUserFeatures, + subscribeCollectiveFeatures, + teardownFeatureSubscriptions + } from '$lib/stores/features'; + import type { FeatureFlags } from '@colectivo/types'; import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { i18n } from '$lib/i18n'; import { initTheme } from '$lib/theme'; @@ -41,6 +47,15 @@ setTimeout(async () => { await loadUserCollectives(session.user.id); + // Fase 12.2: load the user's feature_flags + subscribe to + // realtime updates (own row). Best-effort: a failure here + // only degrades to default-ON for every section. + try { + await loadCurrentUserFeatures(session.user.id); + } catch { + /* non-fatal */ + } + // Fase 10.8: auto-detect language for genuinely new users. // Best-effort, runs after every auth event so newly-seeded // (post-Keycloak-self-registration) rows pick up the user's @@ -71,7 +86,19 @@ } }); - return () => subscription.unsubscribe(); + // Fase 12.2: keep the active-collective realtime subscription in sync. + // Subscribing here (after onAuthStateChange) rather than at the auth + // callback level means it follows the user across collective switches + // without needing a sign-in event. Tear down on layout destroy too. + const collectiveUnsub = currentCollective.subscribe((c) => { + if (c) void subscribeCollectiveFeatures(c.id); + }); + + return () => { + subscription.unsubscribe(); + collectiveUnsub(); + void teardownFeatureSubscriptions(); + }; }); /** @@ -120,16 +147,34 @@ const { data } = await supabase .from('collective_members') - .select('collective_id, role, collectives(id, name, emoji, created_at)') + .select( + 'collective_id, role, collectives(id, name, emoji, created_at, feature_flags)' + ) .eq('user_id', userId); if (!data) return; - type CollectiveRow = { id: string; name: string; emoji: string; created_at: string }; + type CollectiveRow = { + id: string; + name: string; + emoji: string; + created_at: string; + feature_flags: FeatureFlags; + }; const collectives = data .map((row) => { - const c = row.collectives as CollectiveRow | null; - return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null; + const c = row.collectives as + | { id: string; name: string; emoji: string; created_at: string; feature_flags: FeatureFlags | null } + | null; + return c + ? { + id: c.id, + name: c.name, + emoji: c.emoji, + created_at: c.created_at, + feature_flags: (c.feature_flags ?? {}) as FeatureFlags + } + : null; }) .filter((c): c is CollectiveRow => c !== null); diff --git a/apps/web/src/routes/onboarding/+page.svelte b/apps/web/src/routes/onboarding/+page.svelte index 3ac124f..c6ea023 100644 --- a/apps/web/src/routes/onboarding/+page.svelte +++ b/apps/web/src/routes/onboarding/+page.svelte @@ -48,11 +48,10 @@ return; } - const collective = data as { - id: string; - name: string; - emoji: string; - created_at: string; + const collective = { + ...(data as { id: string; name: string; emoji: string; created_at: string }), + // New collective starts with no flags — every section ON by default. + feature_flags: {} as import('@colectivo/types').FeatureFlags }; userCollectives.update((list) => [...list, collective]); diff --git a/packages/test-utils/tests/section-visibility.test.ts b/packages/test-utils/tests/section-visibility.test.ts new file mode 100644 index 0000000..6b3984f --- /dev/null +++ b/packages/test-utils/tests/section-visibility.test.ts @@ -0,0 +1,233 @@ +/** + * SV-series: Section visibility / feature_flags (Fase 12.2). + * + * SV-01 default ON — fresh user + fresh collective, every known section + * resolves to true. + * SV-02 user OFF + collective absent → section OFF for that user, ON for + * everyone else. + * SV-03 collective OFF beats user ON (admin opt-out applies to everyone, + * including users who tried to opt back in). + * SV-04 a user can update their own `feature_flags` (RLS write-path + * piggybacks on users_update_own). + * SV-05 an admin can update the collective `feature_flags`; a non-admin + * member cannot (RLS write-path piggybacks on collectives_update). + * SV-06 the same user in two collectives sees independent collective + * overrides even though the user-level flag is shared (foundational + * check for the SV-03 e2e spec). + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { + createClientAs, + createAdminClient +} from '../src/supabase-clients.js'; +import { + ANA_ID, + BORJA_ID, + CARMEN_ID, + COLLECTIVE_ID +} from '../src/seed-constants.js'; + +const admin = createAdminClient(); +const createdCollectiveIds: string[] = []; + +// Reset both seed rows to a clean `{}` state at the top of every test so the +// previous one doesn't leak. The user/collective rows themselves are seeded +// (and never deleted) — only the JSONB column is reset. +async function resetFlags() { + await admin + .from('users') + .update({ feature_flags: {} }) + .in('id', [ANA_ID, BORJA_ID, CARMEN_ID]); + await admin + .from('collectives') + .update({ feature_flags: {} }) + .eq('id', COLLECTIVE_ID); +} + +afterAll(async () => { + await resetFlags(); + if (createdCollectiveIds.length) { + await admin.from('collectives').delete().in('id', createdCollectiveIds); + } +}); + +describe('section_enabled() precedence (integration)', () => { + it('SV-01: default ON — empty flags on both layers, every known section is enabled', async () => { + await resetFlags(); + const borja = await createClientAs(BORJA_ID); + + const sections = ['lists', 'tasks', 'notes', 'search']; + for (const section of sections) { + const { data, error } = await borja.rpc('section_enabled', { + p_section: section, + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(error).toBeNull(); + expect(data).toBe(true); + } + }); + + it('SV-02: user OFF only — applies to that user, not to a peer in the same collective', async () => { + await resetFlags(); + + // Borja opts out of tasks for himself. + const borja = await createClientAs(BORJA_ID); + const upd = await borja + .from('users') + .update({ feature_flags: { tasks: false } }) + .eq('id', BORJA_ID); + expect(upd.error).toBeNull(); + + // Borja sees tasks OFF. + const borjaEval = await borja.rpc('section_enabled', { + p_section: 'tasks', + p_user: BORJA_ID, + p_collective: COLLECTIVE_ID + }); + expect(borjaEval.error).toBeNull(); + expect(borjaEval.data).toBe(false); + + // Carmen (also a member of the same collective) still sees tasks ON. + const carmen = await createClientAs(CARMEN_ID); + const carmenEval = await carmen.rpc('section_enabled', { + p_section: 'tasks', + p_user: CARMEN_ID, + p_collective: COLLECTIVE_ID + }); + expect(carmenEval.error).toBeNull(); + expect(carmenEval.data).toBe(true); + }); + + it('SV-03: collective OFF beats user ON (admin override applies to everyone)', async () => { + await resetFlags(); + + // Carmen tries to opt INTO notes for herself. + const carmen = await createClientAs(CARMEN_ID); + const carmenSet = await carmen + .from('users') + .update({ feature_flags: { notes: true } }) + .eq('id', CARMEN_ID); + expect(carmenSet.error).toBeNull(); + + // Ana (admin) turns notes OFF for the whole collective. + const ana = await createClientAs(ANA_ID); + const collSet = await ana + .from('collectives') + .update({ feature_flags: { notes: false } }) + .eq('id', COLLECTIVE_ID); + expect(collSet.error).toBeNull(); + + const result = await carmen.rpc('section_enabled', { + p_section: 'notes', + p_user: CARMEN_ID, + p_collective: COLLECTIVE_ID + }); + expect(result.error).toBeNull(); + expect(result.data).toBe(false); + }); +}); + +describe('feature_flags RLS writes', () => { + it('SV-04: a user can update their own feature_flags', async () => { + await resetFlags(); + const borja = await createClientAs(BORJA_ID); + const { error } = await borja + .from('users') + .update({ feature_flags: { search: false } }) + .eq('id', BORJA_ID); + expect(error).toBeNull(); + + const { data } = await borja + .from('users') + .select('feature_flags') + .eq('id', BORJA_ID) + .single(); + expect(data?.feature_flags).toEqual({ search: false }); + }); + + it('SV-05: admin can update collective feature_flags; non-admin member cannot', async () => { + await resetFlags(); + + // Ana (admin) — allowed. + const ana = await createClientAs(ANA_ID); + const anaUpd = await ana + .from('collectives') + .update({ feature_flags: { tasks: false } }) + .eq('id', COLLECTIVE_ID) + .select('feature_flags'); + expect(anaUpd.error).toBeNull(); + expect(anaUpd.data?.[0]?.feature_flags).toEqual({ tasks: false }); + + // Borja (member, not admin) — denied. RLS UPDATE policy returns 0 rows + // updated; PostgREST surfaces this as `data: []` with no error. + const borja = await createClientAs(BORJA_ID); + const borjaUpd = await borja + .from('collectives') + .update({ feature_flags: { tasks: true } }) + .eq('id', COLLECTIVE_ID) + .select('feature_flags'); + expect(borjaUpd.data ?? []).toHaveLength(0); + + // The collective row still reflects Ana's value (admin's call won). + const { data: postState } = await admin + .from('collectives') + .select('feature_flags') + .eq('id', COLLECTIVE_ID) + .single(); + expect(postState?.feature_flags).toEqual({ tasks: false }); + }); +}); + +describe('per-collective isolation', () => { + it('SV-06: a user override applied to one collective context is independent of another (where Ana is also a member)', async () => { + await resetFlags(); + + // Build a second collective that Ana is admin of. + const { data: extra, error: createErr } = await admin + .from('collectives') + .insert({ + name: `SV-06 extra ${Date.now()}`, + emoji: '🧪', + created_by: ANA_ID + }) + .select('id') + .single(); + expect(createErr).toBeNull(); + const extraId = extra!.id; + createdCollectiveIds.push(extraId); + + await admin + .from('collective_members') + .insert({ collective_id: extraId, user_id: ANA_ID, role: 'admin' }); + + // Seed collective: collective OFF tasks. Extra collective: collective ON + // (empty / no opinion) tasks. Ana's user flags are empty. + await admin + .from('collectives') + .update({ feature_flags: { tasks: false } }) + .eq('id', COLLECTIVE_ID); + await admin + .from('collectives') + .update({ feature_flags: {} }) + .eq('id', extraId); + + const ana = await createClientAs(ANA_ID); + + const seedEval = await ana.rpc('section_enabled', { + p_section: 'tasks', + p_user: ANA_ID, + p_collective: COLLECTIVE_ID + }); + expect(seedEval.error).toBeNull(); + expect(seedEval.data).toBe(false); + + const extraEval = await ana.rpc('section_enabled', { + p_section: 'tasks', + p_user: ANA_ID, + p_collective: extraId + }); + expect(extraEval.error).toBeNull(); + expect(extraEval.data).toBe(true); + }); +}); diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index 50d1959..e72144d 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -5,6 +5,22 @@ export type AvatarType = 'initials' | 'emoji' | 'upload'; export type MemberRole = 'admin' | 'member' | 'guest'; export type ListStatus = 'active' | 'completed' | 'archived'; + +/** + * Top-level navigable sections. Must mirror `public.known_sections()` in + * supabase/migrations/023_section_visibility.sql. Adding a section is a + * coordinated diff: migration that updates known_sections() + an entry here + * + toggle rows in /settings and /collective/manage. + */ +export type SectionKey = 'lists' | 'tasks' | 'notes' | 'search'; +export const SECTION_KEYS: readonly SectionKey[] = ['lists', 'tasks', 'notes', 'search'] as const; + +/** + * Per-user or per-collective opt-out map. A missing key means "no opinion" + * — defer to the next layer (collective > user > default ON). See + * `public.section_enabled()` for the canonical resolution. + */ +export type FeatureFlags = Partial>; // NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts import type { NoteColor, ItemTagColor } from './database.js'; export type { NoteColor, ItemTagColor };