diff --git a/CLAUDE.md b/CLAUDE.md index 6daee4d..1c86694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,12 +58,12 @@ All data lives in three tables: `entities`, `relationships`, and `rel_types`. No - **entities**: UUID pk, type (person|organization|event|venue|project|topic), name, slug (unique), JSONB metadata, timestamps - **relationships**: UUID pk, source_id → entities, target_id → entities, rel_type → rel_types, label. UNIQUE(source_id, target_id, rel_type) -- **rel_types**: TEXT pk (key), forward_label, reverse_label, source_types[], target_types[]. Stores relationship type definitions (e.g. `founded` → "Founded" / "Founded by"). Type constraints enable directional validation — the relationships API auto-swaps source/target if they're backwards relative to the type's constraints. Current types: `attended`, `contributes_to`, `founded`, `hosted_at`, `manages`, `member_of`, `organized`, `spoke_at`, `tagged_with`. +- **rel_types**: TEXT pk (key), forward_label, reverse_label, source_types[], target_types[]. Stores relationship type definitions (e.g. `founded` → "Founded" / "Founded by"). Type constraints enable directional validation — the relationships API auto-swaps source/target if they're backwards relative to the type's constraints. Current types: `attended`, `contributes_to`, `created`, `founded`, `hosted_at`, `manages`, `member_of`, `organized`, `spoke_at`, `tagged_with`. - **Search**: pg_trgm GIN index on `entities.name` for fuzzy matching (`%` operator + ILIKE fallback) ### Notifications -In-app + email notification system for relationship request events. Notifications go to the entity owner (from `entity_permissions` where `role = 'owner'`). Three types: `relationship_request_received` (to owner on 202), `_approved` and `_rejected` (to requester on decision). Config is declarative in `lib/config/notifications.ts`. Email via Resend HTTP API (fire-and-forget, raw `fetch`). Signed HMAC-SHA256 links in emails let owners approve/reject without login. `lib/signed-links.ts` handles token creation/verification. See `CLAUDE_DOCS/notifications.md` for full details. +In-app + email notification system. Notifications go to the entity owner (from `entity_permissions` where `role = 'owner'`). Four types: `relationship_request_received` (to owner on 202), `_approved` and `_rejected` (to requester on decision), and `relationship_deleted` (to other side's owner when a relationship is removed). Config is declarative in `lib/config/notifications.ts`. Email via Resend HTTP API (fire-and-forget, raw `fetch`). Signed HMAC-SHA256 links in emails let owners approve/reject without login. `lib/signed-links.ts` handles token creation/verification. See `CLAUDE_DOCS/notifications.md` for full details. ### Rendering model @@ -73,7 +73,7 @@ In-app + email notification system for relationship request events. Notification ### Graph visualization -`components/GraphView.tsx` dynamically imports `react-force-graph-2d` to avoid SSR. Custom canvas rendering via `nodeCanvasObject`. Nodes colored by entity type (colors defined in `lib/config/schema.ts` → `ENTITY_TYPE_CONFIG`). Click expands a node (reveals neighbors), right-click removes, double-click navigates to detail page. Type filter toggles hide/show categories. Subgraph exploration mode with undo/redo stack (command pattern in `lib/hooks/useSubgraphExploration.ts`), saved subgraphs (localStorage via `lib/hooks/useSavedSubgraphs.ts`), and toolbar overlay (`components/SubgraphToolbar.tsx`). Graph page accepts `?seed=` to start exploring from a specific entity. All entity detail pages include an "Explore in graph" link (`components/ExploreGraphButton.tsx`). See `CLAUDE_DOCS/subgraph-mode.md` for full details. +`components/GraphView.tsx` dynamically imports `react-force-graph-2d` to avoid SSR. Custom canvas rendering via `nodeCanvasObject`. Nodes colored by entity type (colors defined in `lib/config/schema.ts` → `ENTITY_TYPE_CONFIG`). Click expands a node (reveals neighbors), right-click removes, double-click navigates to detail page. Type filter toggles hide/show categories. Subgraph exploration mode with undo/redo stack (command pattern in `lib/hooks/useSubgraphExploration.ts`), saved subgraphs (localStorage via `lib/hooks/useSavedSubgraphs.ts`), and toolbar overlay (`components/SubgraphToolbar.tsx`). When a new node joins the subgraph via stub click, all edges to existing committed nodes are auto-discovered and batch-committed (single undo action via `commitEdges()`). Responsive: mobile uses tighter d3-force parameters (charge -600, link distance 100 vs -1500/220 on desktop), smaller zoomToFit padding, and re-zooms after ResizeObserver corrects dimensions. Graph page accepts `?seed=` to start exploring from a specific entity. All entity detail pages include an "Explore in graph" link (`components/ExploreGraphButton.tsx`). See `CLAUDE_DOCS/subgraph-mode.md` for full details. ### URL routing convention @@ -85,7 +85,7 @@ Server-side session auth with bcrypt password hashing. No registration page — ### Entity permissions -Granular edit permissions: admins have full access, users can edit entities they own (via `entity_permissions` table), their own person entity (self-edit), or entities they have an edit-granting relationship with (derived editor). Edit-granting rel types and admin-only rules are defined declaratively in `lib/config/permissions.ts`. Delete remains admin-only. `lib/permissions.ts` provides `canEditEntity()` used by API routes and server components. Entity creators auto-receive `owner` permission + an auto-relationship (configured in `lib/config/schema.ts`). Topics and people are admin-only for creation. Non-admins can create relationships if they can edit the source entity (privilege-granting rel types are admin-only). Single owner per entity (enforced by DB index); ownership is transferable (old owner becomes editor). See `CLAUDE_DOCS/entity-permissions.md` for full details. +Granular edit permissions: admins have full access, users can edit entities they own (via `entity_permissions` table), their own person entity (self-edit), or entities they have an edit-granting relationship with (derived editor). Edit-granting rel types and admin-only rules are defined declaratively in `lib/config/permissions.ts`. Delete: admins and owners can delete entities; relationships can be deleted by anyone who can edit either side. `lib/permissions.ts` provides `canEditEntity()` used by API routes and server components. Entity creators auto-receive `owner` permission + an auto-relationship (configured in `lib/config/schema.ts`). Topics and people are admin-only for creation. Non-admins can create relationships if they can edit the source entity (privilege-granting rel types are admin-only). Single owner per entity (enforced by DB index); ownership is transferable (old owner becomes editor). See `CLAUDE_DOCS/entity-permissions.md` for full details. ### Relationship requests @@ -105,7 +105,7 @@ Logged-in users can claim unowned non-person entities (organizations, projects, ### Image uploads -`/api/upload` accepts multipart form data (admin-only), resizes to 400x400 via sharp, converts to WebP, and uploads to GCS. Images are stored at `entities/{timestamp}-{random}.webp` and referenced by `image_url` on entities. +`/api/upload` accepts multipart form data (any authenticated user), resizes to 400x400 via sharp, converts to WebP. Uploads to GCS when `GCS_BUCKET_NAME` is set; falls back to local filesystem (`public/uploads/`) for development. Images stored at `entities/{timestamp}-{random}.webp` and referenced by `image_url` on entities. The `ImageUpload` component provides drag-and-drop + click-to-browse with an "or paste image URL" fallback link. ## Key files @@ -125,7 +125,7 @@ Logged-in users can claim unowned non-person entities (organizations, projects, - `app/api/auth/confirm-dispute/route.ts` — GET endpoint for email verification of disputes (triggers freeze) - `app/admin/disputes/page.tsx` — Admin disputes management page (list, uphold, revoke) - `components/GraphView.tsx` — Force-directed graph (canvas-based, subgraph exploration via hooks, ResizeObserver for responsive sizing) -- `lib/hooks/useSubgraphExploration.ts` — Undo/redo stack (command pattern), keyboard shortcuts, seed/load/reset for graph exploration +- `lib/hooks/useSubgraphExploration.ts` — Undo/redo stack (command pattern), `commitEdge`/`commitEdges` (single + batch), keyboard shortcuts, seed/load/reset for graph exploration - `lib/hooks/useSavedSubgraphs.ts` — localStorage persistence for saved subgraph snapshots - `components/SubgraphToolbar.tsx` — Overlay toolbar for graph: undo/redo/reset/save/load buttons, node/edge count - `components/ExploreGraphButton.tsx` — "Explore in graph" link on entity detail pages (navigates to `/graph?seed=`) @@ -138,6 +138,10 @@ Logged-in users can claim unowned non-person entities (organizations, projects, - `tests/helpers.ts` — Shared Playwright test utilities (auth flows, CRUD wrappers, permission helpers) used by all spec files - `db/seed.sql` — 36 entities + 40 relationships (PL/pgSQL block with RETURNING INTO for FK references) +### Responsive navbar + +`components/Navbar.tsx` has a responsive layout: desktop shows nav links inline, mobile (< `md` breakpoint) collapses them into a three-dot `...` dropdown. **Important constraint**: there must be exactly ONE `` instance (not separate desktop/mobile copies) — Playwright strict mode fails if duplicate interactive elements exist. The single UserMenu sits between the desktop links and the mobile toggle button, always visible. The mobile dropdown has a click-outside listener to close. + ### Entity detail page pattern All 6 entity detail pages (`app/[type]/[slug]/page.tsx`) follow the same permission pattern: call `getEntityPermissionLevel(user, entityId)` on the server (returns `'admin' | 'owner' | 'editor' | 'none'`), then pass derived booleans (`canEdit`, `isOwner`, `isAdmin`) as props to client components like `InlineAdminControls`. This avoids expensive client-side permission checks. The `isOwner` prop gates the pending-requests fetch in `InlineAdminControls`. diff --git a/app/catalysts/[slug]/page.tsx b/app/catalysts/[slug]/page.tsx new file mode 100644 index 0000000..a167443 --- /dev/null +++ b/app/catalysts/[slug]/page.tsx @@ -0,0 +1,124 @@ +export const dynamic = 'force-dynamic'; + +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { getEntityBySlug, getRelationshipsForEntity, getRelTypesMap, isEntityClaimable } from '@/lib/queries'; +import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { formatDate } from '@/lib/utils'; +import { getCurrentUser } from '@/lib/auth'; +import { getEntityPermissionLevel } from '@/lib/permissions'; +import InlineAdminControls from '@/components/InlineAdminControls'; +import ClaimEntityButton from '@/components/ClaimEntityButton'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const entity = await getEntityBySlug(slug); + if (!entity) return { title: 'Not Found — melb.tech' }; + return { + title: `${entity.name} — melb.tech`, + description: entity.description ?? `${entity.name} — Melbourne tech catalyst.`, + }; +} + +export default async function CatalystPage({ params }: Props) { + const { slug } = await params; + const entity = await getEntityBySlug(slug); + if (!entity || entity.type !== 'catalyst') return notFound(); + + const [relationships, relTypesMap, currentUser] = await Promise.all([ + getRelationshipsForEntity(entity.id), + getRelTypesMap(), + getCurrentUser(), + ]); + const permLevel = await getEntityPermissionLevel(currentUser, entity.id); + const canEdit = permLevel !== 'none'; + const isOwner = permLevel === 'owner' || permLevel === 'admin'; + const claimable = canEdit ? false : await isEntityClaimable(entity.id); + const config = ENTITY_TYPE_CONFIG.catalyst; + + return ( +
+ {/* Main content */} +
+ {/* Header */} +
+ {entity.image_url ? ( + {entity.name} + ) : ( +
+ {config.icon} +
+ )} +
+

{entity.name}

+
+
+ + {/* Claim button */} + {claimable && ( + + )} + + {/* Description */} + {entity.description && ( +
+

{entity.description}

+
+ )} + + {/* Links */} + {entity.website && ( +
+

+ Links +

+ +
+ )} + + {/* Metadata footer */} +

+ Added {formatDate(entity.created_at)} +

+
+ + {/* Sidebar */} + +
+ ); +} diff --git a/app/catalysts/page.tsx b/app/catalysts/page.tsx new file mode 100644 index 0000000..390c664 --- /dev/null +++ b/app/catalysts/page.tsx @@ -0,0 +1,38 @@ +export const dynamic = 'force-dynamic'; + +import { listEntities } from '@/lib/queries'; +import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { getCurrentUser } from '@/lib/auth'; +import EntityGrid from '@/components/EntityGrid'; +import CreateEntityButton from '@/components/CreateEntityButton'; + +export const metadata = { + title: 'Catalysts — melb.tech', + description: 'Accelerators, incubators, and community catalysts in Melbourne\'s tech ecosystem.', +}; + +export default async function CatalystsPage() { + const [catalysts, currentUser] = await Promise.all([ + listEntities('catalyst'), + getCurrentUser(), + ]); + const config = ENTITY_TYPE_CONFIG.catalyst; + + return ( +
+
+
+

+ {config.icon} + {config.plural} +

+ {currentUser && } +
+

+ {catalysts.length} {catalysts.length === 1 ? 'catalyst' : 'catalysts'} in the Melbourne tech community. +

+
+ +
+ ); +} diff --git a/app/getting-started/page.tsx b/app/getting-started/page.tsx new file mode 100644 index 0000000..05949fd --- /dev/null +++ b/app/getting-started/page.tsx @@ -0,0 +1,116 @@ +import Link from 'next/link'; + +const STEPS = [ + { + number: '1', + title: 'Claim your profile', + description: ( + <> + Find yourself on the{' '} + + People + {' '} + page and click “Claim this profile”. + You'll set an email and password — that's your account. + + ), + }, + { + number: '2', + title: 'Add your photo and details', + description: ( + <> + Once claimed, hit Edit on your profile. + Add a photo, a short bio, and any links. Seriously — faceless profiles get ignored. + + ), + }, + { + number: '3', + title: 'Add your projects', + description: ( + <> + Head to{' '} + + Projects + {' '} + and click “Create” to add something + you're working on. You can do the same for{' '} + + Organizations + + ,{' '} + + Events + + ,{' '} + + Venues + + ,{' '} + + Catalysts + + , and{' '} + + Programs + + . Anything you create, you own and can edit. + + ), + }, + { + number: '4', + title: 'Connect the dots', + description: ( + <> + From your profile or project page, add relationships — you founded an org, + spoke at an event, contribute to a project. If you're connecting to something + someone else owns, they'll get a request to approve it. + + ), + }, +]; + +export default function GettingStartedPage() { + return ( +
+
+

Getting Started

+

+ You're already part of Melbourne's tech community. This is how you make that visible. +

+
+ +
+ {STEPS.map((step) => ( +
+
+ {step.number} +
+
+

{step.title}

+

{step.description}

+
+
+ ))} +
+ +
+

+ That's it. Go find yourself in the graph. +

+ + Explore the Community Graph + + +
+
+ ); +} diff --git a/app/graph/page.tsx b/app/graph/page.tsx index ebeb6c8..c4f27b4 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -2,9 +2,9 @@ import type { Metadata } from 'next'; import GraphView from '@/components/GraphView'; export const metadata: Metadata = { - title: 'Knowledge Graph — melb.tech', + title: 'Community Graph — melb.tech', description: - 'Interactive knowledge graph of Melbourne\'s tech ecosystem. Explore connections between people, organizations, events, venues, and projects.', + 'Interactive community graph of Melbourne\'s tech ecosystem. Explore connections between people, organizations, events, venues, and projects.', }; interface Props { @@ -17,7 +17,7 @@ export default async function GraphPage({ searchParams }: Props) { return (
-

Knowledge Graph

+

Community Graph

Hover a stub to preview · Click to connect · Right-click to remove · Double-click to view details · Ctrl+Z to undo

diff --git a/app/page.tsx b/app/page.tsx index 91c01ce..cae0aad 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'; import Link from 'next/link'; import SearchBar from '@/components/SearchBar'; import EntityGrid from '@/components/EntityGrid'; +import LazyGraphView from '@/components/LazyGraphView'; import { getStats, listEntities } from '@/lib/queries'; import { ENTITY_TYPE_CONFIG } from '@/lib/types'; import type { EntityType } from '@/lib/types'; @@ -18,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']; + const statTypes: EntityType[] = ['person', 'organization', 'event', 'venue', 'project', 'catalyst', 'program']; return (
@@ -27,14 +28,14 @@ export default async function HomePage() {

melb.tech

- Discover the people, organizations, events, and projects that make up Melbourne's tech community. + Melbourne's tech community is bigger than you think. Find your place in it.

{/* Stats */} -
+
{statTypes.map(type => { const config = ENTITY_TYPE_CONFIG[type]; const count = stats[type] || 0; @@ -52,15 +53,13 @@ export default async function HomePage() { })}
- {/* Graph CTA */} -
- - Explore the Knowledge Graph - - + {/* Community Graph */} +
+

Community Graph

+

Click a node to explore its connections. See how it all fits together.

+
+ +
{/* Recent Entities */} diff --git a/app/programs/[slug]/page.tsx b/app/programs/[slug]/page.tsx new file mode 100644 index 0000000..c6b6a68 --- /dev/null +++ b/app/programs/[slug]/page.tsx @@ -0,0 +1,124 @@ +export const dynamic = 'force-dynamic'; + +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { getEntityBySlug, getRelationshipsForEntity, getRelTypesMap, isEntityClaimable } from '@/lib/queries'; +import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { formatDate } from '@/lib/utils'; +import { getCurrentUser } from '@/lib/auth'; +import { getEntityPermissionLevel } from '@/lib/permissions'; +import InlineAdminControls from '@/components/InlineAdminControls'; +import ClaimEntityButton from '@/components/ClaimEntityButton'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const entity = await getEntityBySlug(slug); + if (!entity) return { title: 'Not Found — melb.tech' }; + return { + title: `${entity.name} — melb.tech`, + description: entity.description ?? `${entity.name} — Melbourne tech program.`, + }; +} + +export default async function ProgramPage({ params }: Props) { + const { slug } = await params; + const entity = await getEntityBySlug(slug); + if (!entity || entity.type !== 'program') return notFound(); + + const [relationships, relTypesMap, currentUser] = await Promise.all([ + getRelationshipsForEntity(entity.id), + getRelTypesMap(), + getCurrentUser(), + ]); + const permLevel = await getEntityPermissionLevel(currentUser, entity.id); + const canEdit = permLevel !== 'none'; + const isOwner = permLevel === 'owner' || permLevel === 'admin'; + const claimable = canEdit ? false : await isEntityClaimable(entity.id); + const config = ENTITY_TYPE_CONFIG.program; + + return ( +
+ {/* Main content */} +
+ {/* Header */} +
+ {entity.image_url ? ( + {entity.name} + ) : ( +
+ {config.icon} +
+ )} +
+

{entity.name}

+
+
+ + {/* Claim button */} + {claimable && ( + + )} + + {/* Description */} + {entity.description && ( +
+

{entity.description}

+
+ )} + + {/* Links */} + {entity.website && ( +
+

+ Links +

+ +
+ )} + + {/* Metadata footer */} +

+ Added {formatDate(entity.created_at)} +

+
+ + {/* Sidebar */} + +
+ ); +} diff --git a/app/programs/page.tsx b/app/programs/page.tsx new file mode 100644 index 0000000..77ad1e5 --- /dev/null +++ b/app/programs/page.tsx @@ -0,0 +1,38 @@ +export const dynamic = 'force-dynamic'; + +import { listEntities } from '@/lib/queries'; +import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { getCurrentUser } from '@/lib/auth'; +import EntityGrid from '@/components/EntityGrid'; +import CreateEntityButton from '@/components/CreateEntityButton'; + +export const metadata = { + title: 'Programs — melb.tech', + description: 'Bootcamps, cohorts, and structured programs in Melbourne\'s tech ecosystem.', +}; + +export default async function ProgramsPage() { + const [programs, currentUser] = await Promise.all([ + listEntities('program'), + getCurrentUser(), + ]); + const config = ENTITY_TYPE_CONFIG.program; + + return ( +
+
+
+

+ {config.icon} + {config.plural} +

+ {currentUser && } +
+

+ {programs.length} {programs.length === 1 ? 'program' : 'programs'} in the Melbourne tech community. +

+
+ +
+ ); +} diff --git a/components/InlineAdminControls.tsx b/components/InlineAdminControls.tsx index 17ef625..2e42711 100644 --- a/components/InlineAdminControls.tsx +++ b/components/InlineAdminControls.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { entityPath } from '@/lib/utils'; -import { ENTITY_TYPE_CONFIG } from '@/lib/types'; +import { ENTITY_TYPE_CONFIG, ENTITY_TYPES } from '@/lib/types'; import type { Entity, RelationshipWithEntity, RelationshipRequestWithDetails } from '@/lib/types'; import Modal from './Modal'; import AdminEntityForm from './AdminEntityForm'; @@ -53,7 +53,7 @@ export default function InlineAdminControls({ .catch(() => {}); }, [isOwner, isAdmin, entity.id]); - // Group relationships by type + direction (same logic as old RelationshipList) + // Group relationships by type + direction, sorted by entity type then name const groups: Record = {}; for (const rel of relationships) { const labels = relTypesMap[rel.rel_type]; @@ -63,6 +63,16 @@ export default function InlineAdminControls({ if (!groups[groupLabel]) groups[groupLabel] = []; groups[groupLabel].push(rel); } + // Within each group, sort by entity type order (person first, then org, etc.), then by name + const typeOrder = ENTITY_TYPES as readonly string[]; + for (const rels of Object.values(groups)) { + rels.sort((a, b) => { + const typeA = typeOrder.indexOf(a.related_entity.type); + const typeB = typeOrder.indexOf(b.related_entity.type); + if (typeA !== typeB) return typeA - typeB; + return a.related_entity.name.localeCompare(b.related_entity.name); + }); + } async function handleUnclaim() { if (!confirm(`Revoke claim on "${entity.name}"? The linked user account will be deleted.`)) return; diff --git a/components/LazyGraphView.tsx b/components/LazyGraphView.tsx new file mode 100644 index 0000000..8012f88 --- /dev/null +++ b/components/LazyGraphView.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; +import GraphView from './GraphView'; + +export default function LazyGraphView() { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '200px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {visible ? :
} +
+ ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 4fd9053..88e6d23 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -11,8 +11,11 @@ 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' }, + { href: '/getting-started', label: 'Start' }, ]; export default function Navbar() { diff --git a/db/init.sql b/db/init.sql index 450a51a..a72b139 100644 --- a/db/init.sql +++ b/db/init.sql @@ -5,7 +5,7 @@ CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE TABLE entities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type TEXT NOT NULL CHECK (type IN ('person','organization','event','venue','project','topic')), + type TEXT NOT NULL CHECK (type IN ('person','organization','event','venue','project','topic','catalyst','program')), name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT, diff --git a/db/migrations/013_add_catalyst_program.sql b/db/migrations/013_add_catalyst_program.sql new file mode 100644 index 0000000..35f4184 --- /dev/null +++ b/db/migrations/013_add_catalyst_program.sql @@ -0,0 +1,45 @@ +-- Migration 013: Add catalyst and program entity types + new relationship types +-- Also migrates person→project 'founded' rows to 'created' + +-- ─── 1. Expand entity type CHECK constraint ───────────────────────────── +ALTER TABLE entities DROP CONSTRAINT IF EXISTS entities_type_check; +ALTER TABLE entities ADD CONSTRAINT entities_type_check + CHECK (type IN ('person','organization','event','venue','project','topic','catalyst','program')); + +-- ─── 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}') +ON CONFLICT (key) DO NOTHING; + +-- ─── 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}', + target_types = '{event,program}' +WHERE key = 'organized'; + +-- attended: add 'program' to targets +UPDATE rel_types SET target_types = '{event,program}' +WHERE key = 'attended'; + +-- hosted_at: add 'program' to sources +UPDATE rel_types SET source_types = '{event,program}' +WHERE key = 'hosted_at'; + +-- tagged_with: add 'program' and 'catalyst' to sources +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 +UPDATE rel_types SET source_types = '{person,organization,project,program}', + target_types = '{project,event}' +WHERE key = 'created'; + +-- ─── 4. Migrate person→project 'founded' rows to 'created' ───────────── +UPDATE relationships SET rel_type = 'created' +WHERE rel_type = 'founded' + AND target_id IN (SELECT id FROM entities WHERE type = 'project'); diff --git a/db/seed.sql b/db/seed.sql index 5d3de57..e5e6273 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -3,16 +3,18 @@ -- 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,project}'), - ('organized', 'Organized', 'Organized by', '{person,organization,project}', '{event}'), - ('manages', 'Manages', 'Managed by', '{person}', '{venue}'), - ('attended', 'Attended', 'Attended by', '{person}', '{event}'), - ('member_of', 'Member of', 'Member', '{person}', '{organization}'), - ('contributes_to', 'Contributes to', 'Contributor', '{person}', '{project}'), - ('hosted_at', 'Hosted at', 'Hosts', '{event}', '{venue}'), - ('tagged_with', 'Tagged with', 'Tags', '{person,organization,event,venue,project}', '{topic}'), - ('spoke_at', 'Spoke at', 'Speaker', '{person}', '{event}'), - ('created', 'Created', 'Created by', '{person,organization,project}', '{project}'); + ('founded', 'Founded', 'Founded by', '{person}', '{organization}'), + ('organized', 'Organized', '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}'), + ('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}'), + ('participates_in', 'Participates in', 'Participant', '{person}', '{program}'); DO $$ DECLARE diff --git a/lib/config/permissions.ts b/lib/config/permissions.ts index a55d462..bb5b5a3 100644 --- a/lib/config/permissions.ts +++ b/lib/config/permissions.ts @@ -28,7 +28,7 @@ export const ADMIN_ONLY_ENTITY_TYPES: EntityType[] = ['person', 'topic']; // creation via AUTO_RELATIONSHIP_MAP in schema.ts — that's safe because // the user just created the entity. -export const EDIT_GRANTING_REL_TYPES: string[] = ['founded', 'organized', 'manages', 'created']; +export const EDIT_GRANTING_REL_TYPES: string[] = ['founded', 'organized', 'manages', 'created', 'facilitates']; // Relationship types that only admins can create via the API. // Currently identical to EDIT_GRANTING_REL_TYPES. Split into a separate @@ -44,4 +44,4 @@ export const ADMIN_ONLY_REL_TYPES: string[] = EDIT_GRANTING_REL_TYPES; // Exception: if the requester can already edit the target entity // (e.g. they own it), the relationship is auto-approved. -export const REQUIRES_APPROVAL_REL_TYPES: string[] = ['member_of', 'contributes_to']; +export const REQUIRES_APPROVAL_REL_TYPES: string[] = ['member_of', 'contributes_to', 'participates_in']; diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 950a1ae..1228843 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -10,7 +10,7 @@ // ─── Entity Types ───────────────────────────────────────────────────────── -export const ENTITY_TYPES = ['person', 'organization', 'event', 'venue', 'project', 'topic'] as const; +export const ENTITY_TYPES = ['person', 'organization', 'event', 'venue', 'project', 'topic', 'catalyst', 'program'] as const; export type EntityType = typeof ENTITY_TYPES[number]; export interface EntityTypeConfig { @@ -29,6 +29,8 @@ 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' }, + program: { label: 'Program', plural: 'Programs', icon: '📋', color: '#06b6d4', routeSegment: 'programs' }, }; // ─── Relationship Types ─────────────────────────────────────────────────── @@ -48,16 +50,18 @@ export interface RelTypeDefinition { } export const REL_TYPE_DEFINITIONS: RelTypeDefinition[] = [ - { key: 'founded', forwardLabel: 'Founded', reverseLabel: 'Founded by', sourceTypes: ['person'], targetTypes: ['organization', 'project'] }, - { key: 'organized', forwardLabel: 'Organized', reverseLabel: 'Organized by', sourceTypes: ['person', 'organization', 'project'], targetTypes: ['event'] }, - { key: 'manages', forwardLabel: 'Manages', reverseLabel: 'Managed by', sourceTypes: ['person'], targetTypes: ['venue'] }, - { key: 'attended', forwardLabel: 'Attended', reverseLabel: 'Attended by', sourceTypes: ['person'], targetTypes: ['event'] }, - { 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'], targetTypes: ['venue'] }, - { key: 'tagged_with', forwardLabel: 'Tagged with', reverseLabel: 'Tags', sourceTypes: ['person', 'organization', 'event', 'venue', 'project'], 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'], targetTypes: ['project'] }, + { 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: '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'] }, + { 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: 'participates_in', forwardLabel: 'Participates in', reverseLabel: 'Participant', sourceTypes: ['person'], targetTypes: ['program'] }, ]; /** All valid relationship type keys */ @@ -71,7 +75,9 @@ export const REL_TYPE_KEYS = REL_TYPE_DEFINITIONS.map(rt => rt.key); export const AUTO_RELATIONSHIP_MAP: Partial> = { organization: 'founded', - project: 'founded', + project: 'created', event: 'organized', venue: 'manages', + catalyst: 'facilitates', + program: 'organized', }; diff --git a/lib/types.ts b/lib/types.ts index eb06c57..540777c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -58,13 +58,21 @@ export interface TopicMetadata { color?: string; } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CatalystMetadata {} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ProgramMetadata {} + export type EntityMetadata = | PersonMetadata | OrganizationMetadata | EventMetadata | VenueMetadata | ProjectMetadata - | TopicMetadata; + | TopicMetadata + | CatalystMetadata + | ProgramMetadata; // Core entity type export interface Entity {