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.';
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user