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:
@@ -34,7 +34,9 @@
|
||||
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
|
||||
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, created]);
|
||||
currentCollective.set(created);
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface Collective {
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags: FeatureFlags;
|
||||
/**
|
||||
* Fase 18: per-collective default for the create-list modal's title input.
|
||||
* NULL/empty means no prefill — the input opens blank and the user must
|
||||
* type something before submit is enabled.
|
||||
*/
|
||||
default_list_title: string | null;
|
||||
}
|
||||
|
||||
export interface CollectiveMember {
|
||||
|
||||
@@ -240,7 +240,14 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise
|
||||
filter: `id=eq.${collectiveId}`
|
||||
} as never,
|
||||
((payload: {
|
||||
new: { id: string; name: string; emoji: string; created_at: string; feature_flags?: FeatureFlags | null };
|
||||
new: {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
created_at: string;
|
||||
feature_flags?: FeatureFlags | null;
|
||||
default_list_title?: string | null;
|
||||
};
|
||||
}) => {
|
||||
const incoming = payload.new;
|
||||
const flags = (incoming.feature_flags ?? {}) as FeatureFlags;
|
||||
@@ -251,7 +258,8 @@ export async function subscribeCollectiveFeatures(collectiveId: string): Promise
|
||||
...active,
|
||||
name: incoming.name,
|
||||
emoji: incoming.emoji,
|
||||
feature_flags: flags
|
||||
feature_flags: flags,
|
||||
default_list_title: incoming.default_list_title ?? null
|
||||
});
|
||||
}
|
||||
}) as never
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user