feat(fase-18): migration 027 — list title catalog + per-collective default

Adds `collectives.default_list_title` (nullable text) plus a new
`collective_list_titles (collective_id, title)` table — admins curate via
two SECURITY DEFINER RPCs (`add_list_title` / `remove_list_title`, P0002
on non-admin, idempotent on conflict). Members read; direct writes are
all-deny RLS, matching the item_frequency pattern. Table is added to the
`supabase_realtime` publication with REPLICA IDENTITY FULL so the manage
UI sees admin edits live across browsers.

Numbering rules (#N suffix) stay client-side intentionally — the DB is
agnostic about the format so future "YYYY-MM" or other prefixes don't
require a schema migration. The two-clients-race-on-#6 case is accepted
(documented in migration header); a unique constraint would block
deliberate duplicates.

Wires `default_list_title` through every Collective construction site:
loadUserCollectives in root layout, onboarding, CreateCollectiveModal,
features.ts realtime subscriber, and the manage-collective patcher. The
hand-curated `database.ts` gets the new column, table, and two RPC
signatures.

pgTAP `019_list_title_catalog.sql` (13 assertions): schema invariants on
the column + table, PK shape, RLS posture, two-policies count, RPC
existence + SECURITY DEFINER flag, realtime publication membership, and
the default-NULL behaviour. Role-gate denial is covered by the upcoming
list-title-flow integration suite (postgres bypasses auth.uid inside
pgTAP, same caveat as Fase 15's 018_item_frequency_weight.sql).

Tests: 191 pgTAP (was 178; +13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 02:03:41 +02:00
parent 4832981e25
commit bd9a0a6d3f
9 changed files with 336 additions and 9 deletions

View File

@@ -186,7 +186,7 @@
.from('collectives')
.update({ name: trimmed })
.eq('id', $currentCollective.id)
.select('id, name, emoji, created_at, feature_flags')
.select('id, name, emoji, created_at, feature_flags, default_list_title')
.single();
nameSaving = false;
if (updErr || !data) {
@@ -200,6 +200,7 @@
emoji: string;
created_at: string;
feature_flags: import('@colectivo/types').FeatureFlags;
default_list_title: string | null;
}
);
nameSaved = true;
@@ -223,7 +224,7 @@
.from('collectives')
.update({ emoji })
.eq('id', $currentCollective.id)
.select('id, name, emoji, created_at, feature_flags')
.select('id, name, emoji, created_at, feature_flags, default_list_title')
.single();
if (updErr || !data) {
error = updErr?.message ?? 'Failed to save emoji.';
@@ -236,6 +237,7 @@
emoji: string;
created_at: string;
feature_flags: import('@colectivo/types').FeatureFlags;
default_list_title: string | null;
}
);
}
@@ -246,6 +248,7 @@
emoji: string;
created_at: string;
feature_flags: import('@colectivo/types').FeatureFlags;
default_list_title: string | null;
}) {
currentCollective.set(c);
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));

View File

@@ -237,7 +237,7 @@
const { data } = await supabase
.from('collective_members')
.select(
'collective_id, role, collectives(id, name, emoji, created_at, feature_flags)'
'collective_id, role, collectives(id, name, emoji, created_at, feature_flags, default_list_title)'
)
.eq('user_id', userId);
@@ -249,11 +249,19 @@
emoji: string;
created_at: string;
feature_flags: FeatureFlags;
default_list_title: string | null;
};
const collectives = data
.map((row) => {
const c = row.collectives as
| { id: string; name: string; emoji: string; created_at: string; feature_flags: FeatureFlags | null }
| {
id: string;
name: string;
emoji: string;
created_at: string;
feature_flags: FeatureFlags | null;
default_list_title: string | null;
}
| null;
return c
? {
@@ -261,7 +269,8 @@
name: c.name,
emoji: c.emoji,
created_at: c.created_at,
feature_flags: (c.feature_flags ?? {}) as FeatureFlags
feature_flags: (c.feature_flags ?? {}) as FeatureFlags,
default_list_title: c.default_list_title ?? null
}
: null;
})

View File

@@ -51,7 +51,9 @@
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
feature_flags: {} as import('@colectivo/types').FeatureFlags,
// Fase 18: no default list title until an admin sets one.
default_list_title: null as string | null
};
userCollectives.update((list) => [...list, collective]);