From 2d7943ac94468c549c68ce2646fafa54a93880c0 Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Sun, 8 Mar 2026 22:09:55 +1100 Subject: [PATCH 1/7] feat(ux): bidirectional relationships, venue dedup, dispute flow cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminRelForm: direction toggle lets users create relationships with current entity as either source or target, entities sorted by type - Relationships API: owners can request relationships in either direction; admins can approve requests for either side - Events: suppress location metadata when hosted_at relationship exists - People: remove DisputeClaimButton, add dispute guidance to getting-started - Fix "Organized" → "Organizes" forward label in schema/seed/migration Co-Authored-By: Claude Opus 4.6 --- app/api/relationship-requests/[id]/route.ts | 14 +- app/api/relationships/route.ts | 112 +++++----- app/events/[slug]/page.tsx | 20 +- app/getting-started/page.tsx | 17 ++ app/people/[slug]/page.tsx | 13 +- components/AdminRelForm.tsx | 230 +++++++++++++------- components/InlineAdminControls.tsx | 2 +- db/migrations/013_add_catalyst_program.sql | 5 +- db/seed.sql | 2 +- lib/config/schema.ts | 2 +- lib/queries.ts | 8 +- 11 files changed, 261 insertions(+), 164 deletions(-) diff --git a/app/api/relationship-requests/[id]/route.ts b/app/api/relationship-requests/[id]/route.ts index 8af0091..242b603 100644 --- a/app/api/relationship-requests/[id]/route.ts +++ b/app/api/relationship-requests/[id]/route.ts @@ -40,9 +40,17 @@ export async function PATCH( ); } - // Permission: must be able to edit the target entity (owner or admin) - const canEdit = await canEditEntity(user, req.target_id); - if (!canEdit) { + // Permission: must be able to edit the target or source entity (owner or admin). + // Requests can be initiated by either side's owner, so the other side approves. + const [canEditTarget, canEditSource] = await Promise.all([ + canEditEntity(user, req.target_id), + canEditEntity(user, req.source_id), + ]); + // The requester can't approve their own request — the *other* side must approve + const isRequester = user.id === req.requester_id; + const canDecide = (canEditTarget || canEditSource) && !isRequester; + // Admins can always decide + if (!canDecide && !user.is_admin) { return NextResponse.json( { error: 'You do not have permission to decide this request' }, { status: 403 } diff --git a/app/api/relationships/route.ts b/app/api/relationships/route.ts index 3cc83df..a3c7328 100644 --- a/app/api/relationships/route.ts +++ b/app/api/relationships/route.ts @@ -71,7 +71,7 @@ export async function POST(request: NextRequest) { } } - // Permission check: admin can do anything, non-admin must be able to edit the source entity + // Permission check: admin can do anything if (!user.is_admin) { // Privilege-granting rel types are admin-only if (ADMIN_ONLY_REL_TYPES.includes(rel_type)) { @@ -81,62 +81,72 @@ export async function POST(request: NextRequest) { ); } - // Non-admin must have edit permission on the source entity - const canEdit = await canEditEntity(user, sourceId); - if (!canEdit) { + // Non-admin must have edit permission on at least one side + const [canEditSource, canEditTarget] = await Promise.all([ + canEditEntity(user, sourceId), + canEditEntity(user, targetId), + ]); + + if (!canEditSource && !canEditTarget) { return NextResponse.json( { error: 'You do not have permission to add connections to this entity' }, { status: 403 } ); } - // Requires-approval types: create a pending request instead, - // unless the requester can edit the target (auto-approve shortcut). - if (REQUIRES_APPROVAL_REL_TYPES.includes(rel_type)) { - const canEditTarget = await canEditEntity(user, targetId); - if (!canEditTarget) { - try { - const pendingRequest = await createRelationshipRequest( - user.id, - sourceId, - targetId, - rel_type, - label ?? null - ); - - // Fire-and-forget: notify the target entity owner - getEntityOwner(targetId).then(owner => { - if (!owner) return; - const targetRouteSegment = ENTITY_TYPE_CONFIG[target!.type as EntityType]?.routeSegment ?? target!.type; - const tokenExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // 7 days - const approveToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'approve', userId: owner.id, exp: tokenExp }); - const rejectToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'reject', userId: owner.id, exp: tokenExp }); - createNotification(owner.id, 'relationship_request_received', { - requesterName: user.name, - targetName: target!.name, - targetType: target!.type, - targetSlug: target!.slug, - targetRouteSegment, - relLabel: relTypeRow?.forward_label?.toLowerCase() ?? rel_type, - requestId: pendingRequest.id, - approveToken, - rejectToken, - recipientEmail: owner.email, - }).catch(err => console.error('Failed to create request-received notification:', err)); - }).catch(err => console.error('Failed to look up entity owner for notification:', err)); - - return NextResponse.json( - { ...pendingRequest, _type: 'request' }, - { status: 202 } - ); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to create request'; - const isDuplicate = (err as { code?: string })?.code === '23505'; - return NextResponse.json( - { error: isDuplicate ? 'A pending request for this relationship already exists' : message }, - { status: isDuplicate ? 409 : 500 } - ); - } + // Determine if approval is needed: the side the user does NOT control + // must approve. For requires-approval types this is always enforced. + // For other types, approval is needed when the user only controls one side. + const needsApproval = REQUIRES_APPROVAL_REL_TYPES.includes(rel_type) + ? !canEditSource || !canEditTarget // approval unless user controls both sides + : !canEditSource && canEditTarget; // non-approval type: need source owner's OK if user only controls target + + if (needsApproval) { + // Notify the owner of the side the user does NOT control + const approvalEntityId = canEditSource ? targetId : sourceId; + const approvalEntity = canEditSource ? target! : source!; + + try { + const pendingRequest = await createRelationshipRequest( + user.id, + sourceId, + targetId, + rel_type, + label ?? null + ); + + // Fire-and-forget: notify the entity owner who needs to approve + getEntityOwner(approvalEntityId).then(owner => { + if (!owner) return; + const routeSegment = ENTITY_TYPE_CONFIG[approvalEntity.type as EntityType]?.routeSegment ?? approvalEntity.type; + const tokenExp = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60; // 7 days + const approveToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'approve', userId: owner.id, exp: tokenExp }); + const rejectToken = createSignedToken({ purpose: 'notification-action', requestId: pendingRequest.id, action: 'reject', userId: owner.id, exp: tokenExp }); + createNotification(owner.id, 'relationship_request_received', { + requesterName: user.name, + targetName: approvalEntity.name, + targetType: approvalEntity.type, + targetSlug: approvalEntity.slug, + targetRouteSegment: routeSegment, + relLabel: relTypeRow?.forward_label?.toLowerCase() ?? rel_type, + requestId: pendingRequest.id, + approveToken, + rejectToken, + recipientEmail: owner.email, + }).catch(err => console.error('Failed to create request-received notification:', err)); + }).catch(err => console.error('Failed to look up entity owner for notification:', err)); + + return NextResponse.json( + { ...pendingRequest, _type: 'request' }, + { status: 202 } + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to create request'; + const isDuplicate = (err as { code?: string })?.code === '23505'; + return NextResponse.json( + { error: isDuplicate ? 'A pending request for this relationship already exists' : message }, + { status: isDuplicate ? 409 : 500 } + ); } } } diff --git a/app/events/[slug]/page.tsx b/app/events/[slug]/page.tsx index 6d0facc..1041aa1 100644 --- a/app/events/[slug]/page.tsx +++ b/app/events/[slug]/page.tsx @@ -111,7 +111,7 @@ export default async function EventPage({ params }: Props) { )} {/* Date / Time / Duration / Location */} - {(meta.date || meta.time || meta.duration || meta.location) && ( + {(meta.date || meta.time || meta.duration || (meta.location && !venueRel)) && (
{meta.date && (
@@ -144,7 +144,7 @@ export default async function EventPage({ params }: Props) {

{formatDuration(meta.duration)}

)} - {meta.location && ( + {meta.location && !venueRel && (

Location @@ -162,22 +162,6 @@ export default async function EventPage({ params }: Props) {

)} - {/* Venue link */} - {venueRel && ( -
-

- Venue -

- - {ENTITY_TYPE_CONFIG.venue.icon} - {venueRel.related_entity.name} - -
- )} - {/* Links */}

diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx index 05949fd..7a728da 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -111,6 +111,23 @@ export default function GettingStartedPage() {

+ +
+

+ Someone claimed your profile or entity? +

+

+ If you believe someone else has claimed your person profile, organization, project, or any + other entity that belongs to you, please get in touch and we'll sort it out. +

+
+ +

+ If you have any problems please contact:{' '} + + rob42bob@gmail.com + +

); } diff --git a/app/people/[slug]/page.tsx b/app/people/[slug]/page.tsx index 00e76ef..a4585e2 100644 --- a/app/people/[slug]/page.tsx +++ b/app/people/[slug]/page.tsx @@ -11,7 +11,7 @@ import { getEntityPermissionLevel } from '@/lib/permissions'; import InlineAdminControls from '@/components/InlineAdminControls'; import ClaimPersonButton from '@/components/ClaimPersonButton'; import LinkPersonButton from '@/components/LinkPersonButton'; -import DisputeClaimButton from '@/components/DisputeClaimButton'; + interface Props { params: Promise<{ slug: string }>; @@ -162,14 +162,9 @@ export default async function PersonPage({ params }: Props) { )} {/* Metadata footer */} -
-

- Added {formatDate(entity.created_at)} -

- {isClaimed && !currentUser && ( - - )} -
+

+ Added {formatDate(entity.created_at)} +

{/* Sidebar */} diff --git a/components/AdminRelForm.tsx b/components/AdminRelForm.tsx index 5e92bc0..c39a986 100644 --- a/components/AdminRelForm.tsx +++ b/components/AdminRelForm.tsx @@ -2,21 +2,24 @@ import { useState, useEffect, FormEvent } from 'react'; import type { Entity, RelTypeRow } from '@/lib/types'; -import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { ENTITY_TYPE_CONFIG, ENTITY_TYPES } from '@/lib/types'; import { ADMIN_ONLY_REL_TYPES, REQUIRES_APPROVAL_REL_TYPES } from '@/lib/config/permissions'; +type Direction = 'source' | 'target'; + interface AdminRelFormProps { onSave: () => void; onCancel: () => void; - preselectedSourceId?: string; + /** When set, this entity is preselected and the user can toggle whether it's source or target */ + preselectedEntityId?: string; isAdmin?: boolean; } -export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, isAdmin = false }: AdminRelFormProps) { +export default function AdminRelForm({ onSave, onCancel, preselectedEntityId, isAdmin = false }: AdminRelFormProps) { const [entities, setEntities] = useState([]); const [relTypes, setRelTypes] = useState([]); - const [sourceId, setSourceId] = useState(preselectedSourceId ?? ''); + const [sourceId, setSourceId] = useState(preselectedEntityId ?? ''); const [targetId, setTargetId] = useState(''); const [relType, setRelType] = useState(''); const [label, setLabel] = useState(''); @@ -25,6 +28,9 @@ export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, is const [loading, setLoading] = useState(true); const [pendingMessage, setPendingMessage] = useState(''); + // Direction: whether the preselected entity acts as source or target + const [direction, setDirection] = useState('source'); + // Source/target search filter const [sourceFilter, setSourceFilter] = useState(''); const [targetFilter, setTargetFilter] = useState(''); @@ -58,19 +64,62 @@ export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, is }); }, []); + // When direction toggles, swap the preselected entity between source and target + function handleDirectionChange(newDir: Direction) { + if (newDir === direction || !preselectedEntityId) return; + setDirection(newDir); + if (newDir === 'source') { + setSourceId(preselectedEntityId); + setTargetId(''); + } else { + setTargetId(preselectedEntityId); + setSourceId(''); + } + setSourceFilter(''); + setTargetFilter(''); + } + const currentRelType = relTypes.find(rt => rt.key === relType); + // Filter rel types to only those valid for the preselected entity's type in the chosen direction + const preselectedEntity = preselectedEntityId ? entities.find(e => e.id === preselectedEntityId) : null; + const availableRelTypes = relTypes + .filter(rt => isAdmin || !ADMIN_ONLY_REL_TYPES.includes(rt.key)) + .filter(rt => { + if (!preselectedEntity) return true; + if (direction === 'source') { + return rt.source_types.length === 0 || rt.source_types.includes(preselectedEntity.type); + } else { + return rt.target_types.length === 0 || rt.target_types.includes(preselectedEntity.type); + } + }); + + // Reset rel type when direction changes and current selection is no longer valid + useEffect(() => { + if (availableRelTypes.length > 0 && !availableRelTypes.find(rt => rt.key === relType)) { + setRelType(availableRelTypes[0].key); + } + }, [direction, availableRelTypes, relType]); + + const typeOrder = ENTITY_TYPES as readonly string[]; + const sortByTypeAndName = (a: Entity, b: Entity) => { + const typeA = typeOrder.indexOf(a.type); + const typeB = typeOrder.indexOf(b.type); + if (typeA !== typeB) return typeA - typeB; + return a.name.localeCompare(b.name); + }; + const filteredSourceEntities = entities.filter(e => { const matchesFilter = e.name.toLowerCase().includes(sourceFilter.toLowerCase()); const matchesType = !currentRelType || currentRelType.source_types.length === 0 || currentRelType.source_types.includes(e.type); return matchesFilter && matchesType; - }); + }).sort(sortByTypeAndName); const filteredTargetEntities = entities.filter(e => { const matchesFilter = e.name.toLowerCase().includes(targetFilter.toLowerCase()); const matchesType = !currentRelType || currentRelType.target_types.length === 0 || currentRelType.target_types.includes(e.type); return matchesFilter && matchesType; - }); + }).sort(sortByTypeAndName); async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -125,10 +174,67 @@ export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, is 'w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50'; const labelClass = 'block text-sm font-medium text-gray-300 mb-1'; + const isSourceLocked = !!preselectedEntityId && direction === 'source'; + const isTargetLocked = !!preselectedEntityId && direction === 'target'; + if (loading) { return
Loading...
; } + // Render a locked entity display + function renderLockedEntity(entityId: string, side: string) { + const ent = entities.find(e => e.id === entityId); + return ( +
+ +
+ {ent ? `${ENTITY_TYPE_CONFIG[ent.type].icon} ${ent.name}` : 'Loading...'} +
+
+ ); + } + + // Render an entity picker + function renderEntityPicker( + side: string, + value: string, + setValue: (v: string) => void, + filter: string, + setFilter: (v: string) => void, + filteredEntities: Entity[], + ) { + return ( +
+ + setFilter(e.target.value)} + className={inputClass} + placeholder="Filter entities..." + /> + + {value && ( +
+ Selected: {entities.find(e => e.id === value)?.name} +
+ )} +
+ ); + } + return (

Add Relationship

@@ -152,56 +258,54 @@ export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, is )} - {/* Source entity */} - {preselectedSourceId ? ( + {/* Direction toggle — only when entity is preselected */} + {preselectedEntityId && preselectedEntity && (
- -
- {entities.find(e => e.id === preselectedSourceId)?.name ?? 'Loading...'} + +
+ +
- ) : ( -
- - setSourceFilter(e.target.value)} - className={inputClass} - placeholder="Filter entities..." - /> - - {sourceId && ( -
- Selected: {entities.find(e => e.id === sourceId)?.name} -
- )} -
)} + {/* Source entity */} + {isSourceLocked + ? renderLockedEntity(preselectedEntityId!, 'Source') + : renderEntityPicker('Source', sourceId, setSourceId, sourceFilter, setSourceFilter, filteredSourceEntities) + } + {/* Relationship type */}
{!isAdmin && REQUIRES_APPROVAL_REL_TYPES.includes(relType) && (

@@ -211,34 +315,10 @@ export default function AdminRelForm({ onSave, onCancel, preselectedSourceId, is

{/* Target entity */} -
- - setTargetFilter(e.target.value)} - className={inputClass} - placeholder="Filter entities..." - /> - - {targetId && ( -
- Selected: {entities.find(e => e.id === targetId)?.name} -
- )} -
+ {isTargetLocked + ? renderLockedEntity(preselectedEntityId!, 'Target') + : renderEntityPicker('Target', targetId, setTargetId, targetFilter, setTargetFilter, filteredTargetEntities) + } {/* Label */}
diff --git a/components/InlineAdminControls.tsx b/components/InlineAdminControls.tsx index 2e42711..f87446b 100644 --- a/components/InlineAdminControls.tsx +++ b/components/InlineAdminControls.tsx @@ -258,7 +258,7 @@ export default function InlineAdminControls({ {canEdit && ( setAddRelModalOpen(false)}> { setAddRelModalOpen(false); diff --git a/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql index 35f4184..e6ce428 100644 --- a/db/migrations/013_add_catalyst_program.sql +++ b/db/migrations/013_add_catalyst_program.sql @@ -17,8 +17,9 @@ ON CONFLICT (key) DO NOTHING; UPDATE rel_types SET target_types = '{organization}' WHERE key = 'founded'; --- organized: add 'program' to sources and targets -UPDATE rel_types SET source_types = '{person,organization,project,program}', +-- organized: fix label, add 'program' to sources and targets +UPDATE rel_types SET forward_label = 'Organizes', + source_types = '{person,organization,project,program}', target_types = '{event,program}' WHERE key = 'organized'; diff --git a/db/seed.sql b/db/seed.sql index e5e6273..483c7d4 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -4,7 +4,7 @@ -- Seed relationship types — must match lib/config/schema.ts INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_types) VALUES ('founded', 'Founded', 'Founded by', '{person}', '{organization}'), - ('organized', 'Organized', 'Organized by', '{person,organization,project,program}', '{event,program}'), + ('organized', 'Organizes', 'Organized by', '{person,organization,project,program}', '{event,program}'), ('manages', 'Manages', 'Managed by', '{person}', '{venue}'), ('attended', 'Attended', 'Attended by', '{person}', '{event,program}'), ('member_of', 'Member of', 'Member', '{person}', '{organization}'), diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 1228843..1144a62 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -51,7 +51,7 @@ export interface RelTypeDefinition { export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ { key: 'founded', forwardLabel: 'Founded', reverseLabel: 'Founded by', sourceTypes: ['person'], targetTypes: ['organization'] }, - { key: 'organized', forwardLabel: 'Organized', reverseLabel: 'Organized by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['event', 'program'] }, + { key: 'organized', forwardLabel: 'Organizes', reverseLabel: 'Organized by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['event', 'program'] }, { key: 'manages', forwardLabel: 'Manages', reverseLabel: 'Managed by', sourceTypes: ['person'], targetTypes: ['venue'] }, { key: 'attended', forwardLabel: 'Attended', reverseLabel: 'Attended by', sourceTypes: ['person'], targetTypes: ['event', 'program'] }, { key: 'member_of', forwardLabel: 'Member of', reverseLabel: 'Member', sourceTypes: ['person'], targetTypes: ['organization'] }, diff --git a/lib/queries.ts b/lib/queries.ts index 7652591..bb0b2a2 100644 --- a/lib/queries.ts +++ b/lib/queries.ts @@ -573,9 +573,10 @@ export async function getPendingRequestsForUser( return query( `${REQUEST_DETAILS_JOIN} WHERE rr.status = 'pending' + AND rr.requester_id != $1 AND EXISTS ( SELECT 1 FROM entity_permissions ep - WHERE ep.entity_id = rr.target_id + WHERE (ep.entity_id = rr.target_id OR ep.entity_id = rr.source_id) AND ep.user_id = $1 AND ep.role = 'owner' ) @@ -655,15 +656,16 @@ export async function countAllPendingRequests(): Promise { return parseInt(row?.count ?? '0', 10); } -/** Count pending requests for entities the user owns. Used for navbar badge. */ +/** Count pending requests for entities the user owns (either side). Used for navbar badge. */ export async function countPendingRequestsForUser(userId: string): Promise { const row = await queryOne<{ count: string }>( `SELECT COUNT(*) AS count FROM relationship_requests rr WHERE rr.status = 'pending' + AND rr.requester_id != $1 AND EXISTS ( SELECT 1 FROM entity_permissions ep - WHERE ep.entity_id = rr.target_id + WHERE (ep.entity_id = rr.target_id OR ep.entity_id = rr.source_id) AND ep.user_id = $1 AND ep.role = 'owner' )`, From d52d8981c13a9602ee15c6e5acffda411b3cd1f3 Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Sun, 8 Mar 2026 22:33:03 +1100 Subject: [PATCH 2/7] fix(ux): "Attends" label, allow admin self-unclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Attended" → "Attends" forward label (schema, seed, migration) - Allow admins to unclaim their own person entity (still blocks unclaiming other admins) Co-Authored-By: Claude Opus 4.6 --- app/api/auth/unclaim/route.ts | 6 +++--- db/migrations/013_add_catalyst_program.sql | 4 ++-- db/seed.sql | 2 +- lib/config/schema.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/api/auth/unclaim/route.ts b/app/api/auth/unclaim/route.ts index 136f282..f7e6e68 100644 --- a/app/api/auth/unclaim/route.ts +++ b/app/api/auth/unclaim/route.ts @@ -27,10 +27,10 @@ export async function POST(request: NextRequest) { ); } - // Safety: don't let admins unclaim other admins - if (targetUser.is_admin) { + // Safety: don't let admins unclaim *other* admins (self-unclaim is fine) + if (targetUser.is_admin && targetUser.id !== user.id) { return NextResponse.json( - { error: 'Cannot unclaim an admin user' }, + { error: 'Cannot unclaim another admin user' }, { status: 403 } ); } diff --git a/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql index e6ce428..5216bf0 100644 --- a/db/migrations/013_add_catalyst_program.sql +++ b/db/migrations/013_add_catalyst_program.sql @@ -23,8 +23,8 @@ UPDATE rel_types SET forward_label = 'Organizes', target_types = '{event,program}' WHERE key = 'organized'; --- attended: add 'program' to targets -UPDATE rel_types SET target_types = '{event,program}' +-- attended: add 'program' to targets, fix label to present tense +UPDATE rel_types SET forward_label = 'Attends', target_types = '{event,program}' WHERE key = 'attended'; -- hosted_at: add 'program' to sources diff --git a/db/seed.sql b/db/seed.sql index 483c7d4..61db8f0 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -6,7 +6,7 @@ INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_t ('founded', 'Founded', 'Founded by', '{person}', '{organization}'), ('organized', 'Organizes', 'Organized by', '{person,organization,project,program}', '{event,program}'), ('manages', 'Manages', 'Managed by', '{person}', '{venue}'), - ('attended', 'Attended', 'Attended by', '{person}', '{event,program}'), + ('attended', 'Attends', 'Attended by', '{person}', '{event,program}'), ('member_of', 'Member of', 'Member', '{person}', '{organization}'), ('contributes_to', 'Contributes to', 'Contributor', '{person}', '{project}'), ('hosted_at', 'Hosted at', 'Hosts', '{event,program}', '{venue}'), diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 1144a62..edc7160 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -53,7 +53,7 @@ export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ { key: 'founded', forwardLabel: 'Founded', reverseLabel: 'Founded by', sourceTypes: ['person'], targetTypes: ['organization'] }, { key: 'organized', forwardLabel: 'Organizes', reverseLabel: 'Organized by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['event', 'program'] }, { key: 'manages', forwardLabel: 'Manages', reverseLabel: 'Managed by', sourceTypes: ['person'], targetTypes: ['venue'] }, - { key: 'attended', forwardLabel: 'Attended', reverseLabel: 'Attended by', sourceTypes: ['person'], targetTypes: ['event', 'program'] }, + { key: 'attended', forwardLabel: 'Attends', reverseLabel: 'Attended by', sourceTypes: ['person'], targetTypes: ['event', 'program'] }, { key: 'member_of', forwardLabel: 'Member of', reverseLabel: 'Member', sourceTypes: ['person'], targetTypes: ['organization'] }, { key: 'contributes_to', forwardLabel: 'Contributes to', reverseLabel: 'Contributor', sourceTypes: ['person'], targetTypes: ['project'] }, { key: 'hosted_at', forwardLabel: 'Hosted at', reverseLabel: 'Hosts', sourceTypes: ['event', 'program'], targetTypes: ['venue'] }, From fce54225b389bf4aa0f3b110bd6f8b078bf024aa Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Sun, 8 Mar 2026 23:32:08 +1100 Subject: [PATCH 3/7] feat(schema): allow organizations to facilitate entities Add 'organization' to facilitates source types so orgs can facilitate catalysts, programs, projects, and events. Co-Authored-By: Claude Opus 4.6 --- db/migrations/013_add_catalyst_program.sql | 8 ++++++-- db/seed.sql | 2 +- lib/config/schema.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql index 5216bf0..8808050 100644 --- a/db/migrations/013_add_catalyst_program.sql +++ b/db/migrations/013_add_catalyst_program.sql @@ -8,10 +8,14 @@ ALTER TABLE entities ADD CONSTRAINT entities_type_check -- ─── 2. New relationship types ────────────────────────────────────────── INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_types) VALUES - ('facilitates', 'Facilitates', 'Facilitated by', '{person,catalyst}', '{catalyst,program,project,event}'), - ('participates_in', 'Participates in', 'Participant', '{person}', '{program}') + ('facilitates', 'Facilitates', 'Facilitated by', '{person,organization,catalyst}', '{catalyst,program,project,event}'), + ('participates_in', 'Participates in', 'Participant', '{person}', '{program}') ON CONFLICT (key) DO NOTHING; +-- facilitates: add 'organization' to sources +UPDATE rel_types SET source_types = '{person,organization,catalyst}' +WHERE key = 'facilitates'; + -- ─── 3. Update existing relationship types ────────────────────────────── -- founded: remove 'project' from targets → only 'organization' UPDATE rel_types SET target_types = '{organization}' diff --git a/db/seed.sql b/db/seed.sql index 61db8f0..0e9c8d8 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -13,7 +13,7 @@ INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_t ('tagged_with', 'Tagged with', 'Tags', '{person,organization,event,venue,project,program,catalyst}', '{topic}'), ('spoke_at', 'Spoke at', 'Speaker', '{person}', '{event}'), ('created', 'Created', 'Created by', '{person,organization,project,program}', '{project,event}'), - ('facilitates', 'Facilitates', 'Facilitated by', '{person,catalyst}', '{catalyst,program,project,event}'), + ('facilitates', 'Facilitates', 'Facilitated by', '{person,organization,catalyst}', '{catalyst,program,project,event}'), ('participates_in', 'Participates in', 'Participant', '{person}', '{program}'); DO $$ diff --git a/lib/config/schema.ts b/lib/config/schema.ts index edc7160..04a4f53 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -60,7 +60,7 @@ export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ { key: 'tagged_with', forwardLabel: 'Tagged with', reverseLabel: 'Tags', sourceTypes: ['person', 'organization', 'event', 'venue', 'project', 'program', 'catalyst'], targetTypes: ['topic'] }, { key: 'spoke_at', forwardLabel: 'Spoke at', reverseLabel: 'Speaker', sourceTypes: ['person'], targetTypes: ['event'] }, { key: 'created', forwardLabel: 'Created', reverseLabel: 'Created by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['project', 'event'] }, - { key: 'facilitates', forwardLabel: 'Facilitates', reverseLabel: 'Facilitated by', sourceTypes: ['person', 'catalyst'], targetTypes: ['catalyst', 'program', 'project', 'event'] }, + { key: 'facilitates', forwardLabel: 'Facilitates', reverseLabel: 'Facilitated by', sourceTypes: ['person', 'organization', 'catalyst'], targetTypes: ['catalyst', 'program', 'project', 'event'] }, { key: 'participates_in', forwardLabel: 'Participates in', reverseLabel: 'Participant', sourceTypes: ['person'], targetTypes: ['program'] }, ]; From 096d126786f6d4bdf0f291426ded228c99d07452 Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Sun, 8 Mar 2026 23:58:40 +1100 Subject: [PATCH 4/7] fix(perms): make catalyst admin-only, remove from getting-started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only one catalyst exists — prevent regular users from creating more. Remove Catalysts link from step 3 of the getting-started page. Co-Authored-By: Claude Opus 4.6 --- app/getting-started/page.tsx | 4 ---- lib/config/permissions.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx index 7a728da..338f5f7 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -47,10 +47,6 @@ const STEPS = [ Venues - ,{' '} - - Catalysts - , and{' '} Programs diff --git a/lib/config/permissions.ts b/lib/config/permissions.ts index bb5b5a3..2f8d1a9 100644 --- a/lib/config/permissions.ts +++ b/lib/config/permissions.ts @@ -15,7 +15,7 @@ import type { EntityType } from './schema'; // Entity types listed here can only be created by admins. // All other types can be created by any authenticated user. -export const ADMIN_ONLY_ENTITY_TYPES: EntityType[] = ['person', 'topic']; +export const ADMIN_ONLY_ENTITY_TYPES: EntityType[] = ['person', 'topic', 'catalyst']; // ─── Relationship Permissions ───────────────────────────────────────────── // From 98541191effc5a638c48c604711f8cacdb7790a3 Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Mon, 9 Mar 2026 00:24:56 +1100 Subject: [PATCH 5/7] fix(copy): make dispute guidance actionable with email link The "someone claimed your profile" section said "get in touch" without saying how. Now includes the email address directly. Co-Authored-By: Claude Opus 4.6 --- app/getting-started/page.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx index 338f5f7..5802d37 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -113,13 +113,17 @@ export default function GettingStartedPage() { Someone claimed your profile or entity?

- If you believe someone else has claimed your person profile, organization, project, or any - other entity that belongs to you, please get in touch and we'll sort it out. + If someone else has claimed your person profile, organization, project, or any + other entity that belongs to you, email{' '} + + rob42bob@gmail.com + {' '} + and we'll sort it out.

- If you have any problems please contact:{' '} + Any other problems? Contact:{' '} rob42bob@gmail.com From 8f375582641977a7dac2b030854a71d272669007 Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Mon, 9 Mar 2026 06:28:28 +1100 Subject: [PATCH 6/7] feat(schema): allow created relationship to target programs Person, organization, and project can now create programs via the 'created' relationship type. Co-Authored-By: Claude Opus 4.6 --- db/migrations/013_add_catalyst_program.sql | 4 ++-- db/seed.sql | 2 +- lib/config/schema.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql index 8808050..b3c2fd9 100644 --- a/db/migrations/013_add_catalyst_program.sql +++ b/db/migrations/013_add_catalyst_program.sql @@ -39,9 +39,9 @@ WHERE key = 'hosted_at'; UPDATE rel_types SET source_types = '{person,organization,event,venue,project,program,catalyst}' WHERE key = 'tagged_with'; --- created: add 'program' to sources, add 'event' to targets +-- created: add 'program' to sources, add 'event' and 'program' to targets UPDATE rel_types SET source_types = '{person,organization,project,program}', - target_types = '{project,event}' + target_types = '{project,event,program}' WHERE key = 'created'; -- ─── 4. Migrate person→project 'founded' rows to 'created' ───────────── diff --git a/db/seed.sql b/db/seed.sql index 0e9c8d8..bfd11cb 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -12,7 +12,7 @@ INSERT INTO rel_types (key, forward_label, reverse_label, source_types, target_t ('hosted_at', 'Hosted at', 'Hosts', '{event,program}', '{venue}'), ('tagged_with', 'Tagged with', 'Tags', '{person,organization,event,venue,project,program,catalyst}', '{topic}'), ('spoke_at', 'Spoke at', 'Speaker', '{person}', '{event}'), - ('created', 'Created', 'Created by', '{person,organization,project,program}', '{project,event}'), + ('created', 'Created', 'Created by', '{person,organization,project,program}', '{project,event,program}'), ('facilitates', 'Facilitates', 'Facilitated by', '{person,organization,catalyst}', '{catalyst,program,project,event}'), ('participates_in', 'Participates in', 'Participant', '{person}', '{program}'); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 04a4f53..7c562b1 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -59,7 +59,7 @@ export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ { key: 'hosted_at', forwardLabel: 'Hosted at', reverseLabel: 'Hosts', sourceTypes: ['event', 'program'], targetTypes: ['venue'] }, { key: 'tagged_with', forwardLabel: 'Tagged with', reverseLabel: 'Tags', sourceTypes: ['person', 'organization', 'event', 'venue', 'project', 'program', 'catalyst'], targetTypes: ['topic'] }, { key: 'spoke_at', forwardLabel: 'Spoke at', reverseLabel: 'Speaker', sourceTypes: ['person'], targetTypes: ['event'] }, - { key: 'created', forwardLabel: 'Created', reverseLabel: 'Created by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['project', 'event'] }, + { key: 'created', forwardLabel: 'Created', reverseLabel: 'Created by', sourceTypes: ['person', 'organization', 'project', 'program'], targetTypes: ['project', 'event', 'program'] }, { key: 'facilitates', forwardLabel: 'Facilitates', reverseLabel: 'Facilitated by', sourceTypes: ['person', 'organization', 'catalyst'], targetTypes: ['catalyst', 'program', 'project', 'event'] }, { key: 'participates_in', forwardLabel: 'Participates in', reverseLabel: 'Participant', sourceTypes: ['person'], targetTypes: ['program'] }, ]; From d6dcc766ef2eb37216baca4e63ad875ae269da9e Mon Sep 17 00:00:00 2001 From: Robin Langer Date: Mon, 9 Mar 2026 06:30:00 +1100 Subject: [PATCH 7/7] feat(ux): hide catalyst from navbar, homepage, and graph filters Add hidden flag to EntityTypeConfig. Catalyst still exists in the schema and DB but is no longer visible in navigation or stats. The /catalysts page still works via direct URL. Co-Authored-By: Claude Opus 4.6 --- app/page.tsx | 2 +- components/GraphView.tsx | 2 +- components/Navbar.tsx | 1 - lib/config/schema.ts | 4 +++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index cae0aad..535d90c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -19,7 +19,7 @@ export default async function HomePage() { .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 6); - const statTypes: EntityType[] = ['person', 'organization', 'event', 'venue', 'project', 'catalyst', 'program']; + const statTypes: EntityType[] = ['person', 'organization', 'event', 'venue', 'project', 'program']; return (

diff --git a/components/GraphView.tsx b/components/GraphView.tsx index 87e5fc9..2331bd1 100644 --- a/components/GraphView.tsx +++ b/components/GraphView.tsx @@ -845,7 +845,7 @@ export default function GraphView({ className, seedEntityId }: GraphViewProps) {
{/* Entity type filter toggles */}
- {ENTITY_TYPES.map((type) => { + {ENTITY_TYPES.filter((type) => !ENTITY_TYPE_CONFIG[type].hidden).map((type) => { const config = ENTITY_TYPE_CONFIG[type]; const isHidden = hiddenTypes.has(type); return ( diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 88e6d23..e2fb623 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -11,7 +11,6 @@ const NAV_ITEMS = [ { href: '/events', label: 'Events' }, { href: '/venues', label: 'Venues' }, { href: '/projects', label: 'Projects' }, - { href: '/catalysts', label: 'Catalysts' }, { href: '/programs', label: 'Programs' }, { href: '/topics', label: 'Topics' }, { href: '/graph', label: 'Graph' }, diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 7c562b1..d462801 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -20,6 +20,8 @@ export interface EntityTypeConfig { color: string; /** URL route segment: person → 'people', organization → 'orgs', etc. */ routeSegment: string; + /** If true, hide from navbar, homepage stats, and graph type filters */ + hidden?: boolean; } export const ENTITY_TYPE_CONFIG: Record = { @@ -29,7 +31,7 @@ export const ENTITY_TYPE_CONFIG: Record = { venue: { label: 'Venue', plural: 'Venues', icon: '📍', color: '#ef4444', routeSegment: 'venues' }, project: { label: 'Project', plural: 'Projects', icon: '💻', color: '#8b5cf6', routeSegment: 'projects' }, topic: { label: 'Topic', plural: 'Topics', icon: '🏷️', color: '#ec4899', routeSegment: 'topics' }, - catalyst: { label: 'Catalyst', plural: 'Catalysts', icon: '⚡', color: '#f97316', routeSegment: 'catalysts' }, + catalyst: { label: 'Catalyst', plural: 'Catalysts', icon: '⚡', color: '#f97316', routeSegment: 'catalysts', hidden: true }, program: { label: 'Program', plural: 'Programs', icon: '📋', color: '#06b6d4', routeSegment: 'programs' }, };