Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=<entityId>` 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=<entityId>` 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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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=<id>`)
Expand All @@ -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 `<UserMenu />` 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`.
Expand Down
124 changes: 124 additions & 0 deletions app/catalysts/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
{entity.image_url ? (
<img
src={entity.image_url}
alt={entity.name}
className="w-20 h-20 rounded-lg object-cover border-2 border-white/10"
/>
) : (
<div
className="w-20 h-20 rounded-lg flex items-center justify-center text-3xl border-2 border-white/10"
style={{ backgroundColor: config.color + '20' }}
>
{config.icon}
</div>
)}
<div>
<h1 className="text-3xl font-bold text-white">{entity.name}</h1>
</div>
</div>

{/* Claim button */}
{claimable && (
<ClaimEntityButton
entityId={entity.id}
entityType={entity.type}
entityName={entity.name}
isLoggedIn={!!currentUser}
currentPath={`/catalysts/${entity.slug}`}
/>
)}

{/* Description */}
{entity.description && (
<div className="bg-white/5 border border-white/10 rounded-lg p-6">
<p className="text-gray-300 leading-relaxed">{entity.description}</p>
</div>
)}

{/* Links */}
{entity.website && (
<div>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Links
</h2>
<div className="flex flex-wrap gap-3">
<a
href={entity.website}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 hover:text-white hover:border-white/20 transition-colors"
>
Website
</a>
</div>
</div>
)}

{/* Metadata footer */}
<p className="text-xs text-gray-600">
Added {formatDate(entity.created_at)}
</p>
</div>

{/* Sidebar */}
<InlineAdminControls
entity={entity}
relationships={relationships}
relTypesMap={relTypesMap}
backHref="/catalysts"
backLabel="Catalysts"
isAdmin={currentUser?.is_admin ?? false}
canEdit={canEdit}
isOwner={isOwner}
/>
</div>
);
}
38 changes: 38 additions & 0 deletions app/catalysts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div className="mb-8">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
<span>{config.icon}</span>
<span>{config.plural}</span>
</h1>
{currentUser && <CreateEntityButton entityType="catalyst" />}
</div>
<p className="text-gray-400 mt-2">
{catalysts.length} {catalysts.length === 1 ? 'catalyst' : 'catalysts'} in the Melbourne tech community.
</p>
</div>
<EntityGrid entities={catalysts} emptyMessage="No catalysts found yet." />
</div>
);
}
116 changes: 116 additions & 0 deletions app/getting-started/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Link from 'next/link';

const STEPS = [
{
number: '1',
title: 'Claim your profile',
description: (
<>
Find yourself on the{' '}
<Link href="/people" className="text-blue-400 hover:text-blue-300 transition-colors underline">
People
</Link>{' '}
page and click <strong className="text-white">&ldquo;Claim this profile&rdquo;</strong>.
You&apos;ll set an email and password &mdash; that&apos;s your account.
</>
),
},
{
number: '2',
title: 'Add your photo and details',
description: (
<>
Once claimed, hit <strong className="text-white">Edit</strong> on your profile.
Add a photo, a short bio, and any links. Seriously &mdash; faceless profiles get ignored.
</>
),
},
{
number: '3',
title: 'Add your projects',
description: (
<>
Head to{' '}
<Link href="/projects" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Projects
</Link>{' '}
and click <strong className="text-white">&ldquo;Create&rdquo;</strong> to add something
you&apos;re working on. You can do the same for{' '}
<Link href="/orgs" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Organizations
</Link>
,{' '}
<Link href="/events" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Events
</Link>
,{' '}
<Link href="/venues" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Venues
</Link>
,{' '}
<Link href="/catalysts" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Catalysts
</Link>
, and{' '}
<Link href="/programs" className="text-blue-400 hover:text-blue-300 transition-colors underline">
Programs
</Link>
. Anything you create, you own and can edit.
</>
),
},
{
number: '4',
title: 'Connect the dots',
description: (
<>
From your profile or project page, add relationships &mdash; you founded an org,
spoke at an event, contribute to a project. If you&apos;re connecting to something
someone else owns, they&apos;ll get a request to approve it.
</>
),
},
];

export default function GettingStartedPage() {
return (
<div className="max-w-2xl mx-auto space-y-10 py-12 px-4">
<div className="text-center space-y-3">
<h1 className="text-4xl font-bold text-white">Getting Started</h1>
<p className="text-lg text-gray-400">
You&apos;re already part of Melbourne&apos;s tech community. This is how you make that visible.
</p>
</div>

<div className="space-y-4">
{STEPS.map((step) => (
<div
key={step.number}
className="bg-white/5 border border-white/10 rounded-xl p-6 flex gap-5"
>
<div className="flex-shrink-0 w-9 h-9 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
{step.number}
</div>
<div>
<h2 className="text-lg font-semibold text-white mb-1">{step.title}</h2>
<p className="text-gray-400 text-sm leading-relaxed">{step.description}</p>
</div>
</div>
))}
</div>

<div className="text-center pt-2">
<p className="text-gray-400 mb-4">
That&apos;s it. Go find yourself in the graph.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg transition-colors"
>
Explore the Community Graph
<span className="text-lg">&rarr;</span>
</Link>
</div>
</div>
);
}
Loading