feat(fase-12): features store + integration coverage
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 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,11 @@
|
|||||||
error = rpcErr?.message ?? 'Failed to create collective.';
|
error = rpcErr?.message ?? 'Failed to create collective.';
|
||||||
return;
|
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]);
|
userCollectives.update((list) => [...list, created]);
|
||||||
currentCollective.set(created);
|
currentCollective.set(created);
|
||||||
localStorage.setItem('activeCollectiveId', created.id);
|
localStorage.setItem('activeCollectiveId', created.id);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
import type { FeatureFlags } from '@colectivo/types';
|
||||||
|
|
||||||
export interface Collective {
|
export interface Collective {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
feature_flags: FeatureFlags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectiveMember {
|
export interface CollectiveMember {
|
||||||
|
|||||||
282
apps/web/src/lib/stores/features.ts
Normal file
282
apps/web/src/lib/stores/features.ts
Normal file
@@ -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<SectionKey, boolean> = {} as Record<SectionKey, boolean>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await teardownUserChannel();
|
||||||
|
await teardownCollectiveChannel();
|
||||||
|
currentUserFeatures.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardownUserChannel(): Promise<void> {
|
||||||
|
if (!userChannel) return;
|
||||||
|
const supabase = getSupabase();
|
||||||
|
try {
|
||||||
|
await userChannel.unsubscribe();
|
||||||
|
await supabase.removeChannel(userChannel);
|
||||||
|
} catch {
|
||||||
|
/* idempotent */
|
||||||
|
}
|
||||||
|
userChannel = null;
|
||||||
|
userChannelUserId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardownCollectiveChannel(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await new Promise<void>((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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -171,14 +171,22 @@
|
|||||||
.from('collectives')
|
.from('collectives')
|
||||||
.update({ name: trimmed })
|
.update({ name: trimmed })
|
||||||
.eq('id', $currentCollective.id)
|
.eq('id', $currentCollective.id)
|
||||||
.select('id, name, emoji, created_at')
|
.select('id, name, emoji, created_at, feature_flags')
|
||||||
.single();
|
.single();
|
||||||
nameSaving = false;
|
nameSaving = false;
|
||||||
if (updErr || !data) {
|
if (updErr || !data) {
|
||||||
error = updErr?.message ?? 'Failed to save name.';
|
error = updErr?.message ?? 'Failed to save name.';
|
||||||
return;
|
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;
|
nameSaved = true;
|
||||||
setTimeout(() => (nameSaved = false), 1500);
|
setTimeout(() => (nameSaved = false), 1500);
|
||||||
}
|
}
|
||||||
@@ -200,16 +208,30 @@
|
|||||||
.from('collectives')
|
.from('collectives')
|
||||||
.update({ emoji })
|
.update({ emoji })
|
||||||
.eq('id', $currentCollective.id)
|
.eq('id', $currentCollective.id)
|
||||||
.select('id, name, emoji, created_at')
|
.select('id, name, emoji, created_at, feature_flags')
|
||||||
.single();
|
.single();
|
||||||
if (updErr || !data) {
|
if (updErr || !data) {
|
||||||
error = updErr?.message ?? 'Failed to save emoji.';
|
error = updErr?.message ?? 'Failed to save emoji.';
|
||||||
return;
|
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);
|
currentCollective.set(c);
|
||||||
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { currentUser, authLoading } from '$lib/stores/auth';
|
import { currentUser, authLoading } from '$lib/stores/auth';
|
||||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
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 { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||||
import { i18n } from '$lib/i18n';
|
import { i18n } from '$lib/i18n';
|
||||||
import { initTheme } from '$lib/theme';
|
import { initTheme } from '$lib/theme';
|
||||||
@@ -41,6 +47,15 @@
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await loadUserCollectives(session.user.id);
|
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.
|
// Fase 10.8: auto-detect language for genuinely new users.
|
||||||
// Best-effort, runs after every auth event so newly-seeded
|
// Best-effort, runs after every auth event so newly-seeded
|
||||||
// (post-Keycloak-self-registration) rows pick up the user's
|
// (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
|
const { data } = await supabase
|
||||||
.from('collective_members')
|
.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);
|
.eq('user_id', userId);
|
||||||
|
|
||||||
if (!data) return;
|
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
|
const collectives = data
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const c = row.collectives as CollectiveRow | null;
|
const c = row.collectives as
|
||||||
return c ? { id: c.id, name: c.name, emoji: c.emoji, created_at: c.created_at } : null;
|
| { 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);
|
.filter((c): c is CollectiveRow => c !== null);
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const collective = data as {
|
const collective = {
|
||||||
id: string;
|
...(data as { id: string; name: string; emoji: string; created_at: string }),
|
||||||
name: string;
|
// New collective starts with no flags — every section ON by default.
|
||||||
emoji: string;
|
feature_flags: {} as import('@colectivo/types').FeatureFlags
|
||||||
created_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
userCollectives.update((list) => [...list, collective]);
|
userCollectives.update((list) => [...list, collective]);
|
||||||
|
|||||||
233
packages/test-utils/tests/section-visibility.test.ts
Normal file
233
packages/test-utils/tests/section-visibility.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,22 @@
|
|||||||
export type AvatarType = 'initials' | 'emoji' | 'upload';
|
export type AvatarType = 'initials' | 'emoji' | 'upload';
|
||||||
export type MemberRole = 'admin' | 'member' | 'guest';
|
export type MemberRole = 'admin' | 'member' | 'guest';
|
||||||
export type ListStatus = 'active' | 'completed' | 'archived';
|
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<Record<SectionKey, boolean>>;
|
||||||
// NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
|
// NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
|
||||||
import type { NoteColor, ItemTagColor } from './database.js';
|
import type { NoteColor, ItemTagColor } from './database.js';
|
||||||
export type { NoteColor, ItemTagColor };
|
export type { NoteColor, ItemTagColor };
|
||||||
|
|||||||
Reference in New Issue
Block a user