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/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..5802d37 100644 --- a/app/getting-started/page.tsx +++ b/app/getting-started/page.tsx @@ -47,10 +47,6 @@ const STEPS = [ Venues - ,{' '} - - Catalysts - , and{' '} Programs @@ -111,6 +107,27 @@ export default function GettingStartedPage() {

+ +
+

+ Someone claimed your profile or entity? +

+

+ 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. +

+
+ +

+ Any other problems? Contact:{' '} + + rob42bob@gmail.com + +

); } 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/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/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/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/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/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql index 35f4184..b3c2fd9 100644 --- a/db/migrations/013_add_catalyst_program.sql +++ b/db/migrations/013_add_catalyst_program.sql @@ -8,22 +8,27 @@ 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}' 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'; --- 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 @@ -34,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 e5e6273..bfd11cb 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -4,16 +4,16 @@ -- 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}'), + ('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}'), ('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}'), + ('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}'); DO $$ 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 ───────────────────────────────────────────── // diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 1228843..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' }, }; @@ -51,16 +53,16 @@ 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: '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'] }, { 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: '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'] }, ]; 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' )`,