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:
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user