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:
2026-05-18 03:53:54 +02:00
parent 59a32f80f7
commit 59c425f6f6
8 changed files with 619 additions and 16 deletions

View File

@@ -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)));
}