Skip to content
Closed
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
128 changes: 124 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,125 @@
# Ruby on Rails Tutorial: first application
# Business Leader Network

This is the first application for the
[*Ruby on Rails Tutorial*](http://railstutorial.org/)
by [Michael Hartl](http://michaelhartl.com/).
A graph-powered B2B sales and marketing intelligence application built with Ruby on Rails and Neo4j — inspired by [Kantwert's presentation at GraphConnect](https://neo4j.com/blog/cypher-and-gql/the-power-of-business-leader-networks/).

## What it does

Maps professional relationships between business leaders so sales and marketing teams can:

- **Find introduction paths** — shortest connection chain to any target leader ("6 degrees" warm intro)
- **Identify top connectors** — bridge nodes that unlock the largest portions of the network
- **Target by industry** — all reachable leaders in FinTech, HealthTech, CleanTech, etc.
- **Explore company networks** — employees, board members, and alumni for any company
- **Analyse mutual connections** — shared contacts between any two leaders

## Graph Schema

```
(:BusinessLeader {id, name, title, email, linkedin_url, bio, influence_score})
-[:WORKS_AT {since, current}]-> (:Company)
-[:KNOWS {since, source, strength}]-> (:BusinessLeader)
-[:BOARD_MEMBER_OF {since}]-> (:Company)
-[:PREVIOUSLY_WORKED_AT {from, to, title}]-> (:Company)

(:Company {id, name, industry, size, hq_city, website, description})
(:Industry {name})
```

## Key Cypher Queries

**Shortest introduction path**
```cypher
MATCH path = shortestPath(
(source:BusinessLeader {id: $source_id})-[:KNOWS*..6]-(target:BusinessLeader {id: $target_id})
)
RETURN nodes(path)
```

**Extended network within N hops**
```cypher
MATCH (source:BusinessLeader {id: $id})-[:KNOWS*1..$hops]-(reachable:BusinessLeader)
WHERE source <> reachable
RETURN DISTINCT reachable ORDER BY reachable.influence_score DESC
```

**Top connectors (bridge nodes)**
```cypher
MATCH (l:BusinessLeader)-[:KNOWS]-(other)
WITH l, count(DISTINCT other) AS degree
RETURN l.name, degree ORDER BY degree DESC
```

**B2B industry targeting**
```cypher
MATCH (l:BusinessLeader)-[:WORKS_AT {current: true}]->(c:Company {industry: $industry})
OPTIONAL MATCH (l)-[:KNOWS]-(other)
WITH l, c, count(DISTINCT other) AS connections
RETURN l.name, c.name, connections ORDER BY connections DESC
```

**Mutual connections**
```cypher
MATCH (a:BusinessLeader {id: $id})-[:KNOWS]-(mutual)-[:KNOWS]-(b:BusinessLeader {id: $other_id})
WHERE a <> b AND mutual <> a AND mutual <> b
RETURN DISTINCT mutual
```

## Setup

### 1. Install & start Neo4j

Download Neo4j Community Edition from https://neo4j.com/download/
Default credentials: `neo4j` / `password` (change via Neo4j Browser on first login)

### 2. Configure environment

```bash
export NEO4J_URL=http://localhost:7474
export NEO4J_USERNAME=neo4j
export NEO4J_PASSWORD=your_password
```

### 3. Install Ruby dependencies

```bash
bundle install
```

### 4. Seed the graph

```bash
rake db:seed
```

This loads 20 business leaders across 8 companies (FinTech, Data & AI, Cybersecurity, HealthTech, Real Estate, SaaS/ERP, CleanTech, Media), 38 KNOWS relationships, 7 board seats, and 7 work-history entries.

### 5. Start the server

```bash
rails server
```

Open http://localhost:3000

## Rake Tasks

```bash
rake neo4j:status # Check connection and print graph stats
rake neo4j:top_connectors # Print top-connected leaders to stdout
rake neo4j:reset # Clear all graph data (prompts for confirmation)
rake CYPHER='...' neo4j:query # Run a raw Cypher query
```

## Architecture

| File | Purpose |
|------|---------|
| `lib/neo4j/client.rb` | Raw HTTP client for Neo4j's transactional Cypher endpoint |
| `config/initializers/neo4j.rb` | Boot-time connection + schema/index creation |
| `app/models/business_leader.rb` | Graph ORM for `:BusinessLeader` nodes + path queries |
| `app/models/company.rb` | Graph ORM for `:Company` nodes |
| `app/controllers/business_leaders_controller.rb` | Profile, network, path, mutual views |
| `app/controllers/companies_controller.rb` | Company directory and detail |
| `app/controllers/network_controller.rb` | Dashboard, top connectors, industry targeting |
| `db/seeds.rb` | Sample graph with 20 leaders, 8 companies, 38 connections |
| `lib/tasks/neo4j.rake` | Operational rake tasks |
56 changes: 56 additions & 0 deletions app/controllers/business_leaders_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
class BusinessLeadersController < ApplicationController
# GET /business_leaders
def index
@leaders = if params[:q].present?
BusinessLeader.search(params[:q])
else
BusinessLeader.all
end
end

# GET /business_leaders/:id
def show
@leader = BusinessLeader.find(params[:id])
return render :not_found unless @leader

@connections = @leader.connections
@current_company = @leader.current_company
@board_seats = @leader.board_seats
@work_history = @leader.work_history
end

# GET /business_leaders/:id/network
# Returns the extended network up to N hops for B2B targeting
def network
@leader = BusinessLeader.find(params[:id])
return render :not_found unless @leader

hops = (params[:hops] || 2).to_i.clamp(1, 3)
@network_leaders = @leader.network_within(hops)
@hops = hops
end

# GET /business_leaders/:id/path?target_id=…
# Introduction chain: who can introduce me to the target leader?
def path
@leader = BusinessLeader.find(params[:id])
return render :not_found unless @leader

@target = BusinessLeader.find(params[:target_id])
return render :not_found unless @target

@path = @leader.introduction_path_to(@target.id)
@degrees = @path.length - 1
end

# GET /business_leaders/:id/mutual?other_id=…
def mutual
@leader = BusinessLeader.find(params[:id])
return render :not_found unless @leader

@other = BusinessLeader.find(params[:other_id])
return render :not_found unless @other

@mutual = @leader.mutual_connections_with(@other.id)
end
end
21 changes: 21 additions & 0 deletions app/controllers/companies_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class CompaniesController < ApplicationController
# GET /companies
def index
@companies = if params[:industry].present?
Company.by_industry(params[:industry])
else
Company.all
end
@industries = Company.industries
end

# GET /companies/:id
def show
@company = Company.find(params[:id])
return render :not_found unless @company

@employees = @company.current_employees
@board = @company.board_members
@alumni = @company.alumni
end
end
67 changes: 67 additions & 0 deletions app/controllers/network_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# High-level B2B intelligence queries that span the whole graph
class NetworkController < ApplicationController
# GET /network
# Dashboard with top influencers and recent stats
def index
@top_leaders = NEO4J.query(<<~CYPHER)
MATCH (l:BusinessLeader)
OPTIONAL MATCH (l)-[:WORKS_AT {current: true}]->(c:Company)
RETURN l.id AS id, l.name AS name, l.title AS title,
c.name AS company_name, l.influence_score AS influence_score
ORDER BY l.influence_score DESC
LIMIT 10
CYPHER

@stats = {
leaders: NEO4J.query('MATCH (l:BusinessLeader) RETURN count(l) AS n').first&.fetch('n', 0),
companies: NEO4J.query('MATCH (c:Company) RETURN count(c) AS n').first&.fetch('n', 0),
connections: NEO4J.query('MATCH ()-[r:KNOWS]->() RETURN count(r) AS n').first&.fetch('n', 0),
board_seats: NEO4J.query('MATCH ()-[r:BOARD_MEMBER_OF]->() RETURN count(r) AS n').first&.fetch('n', 0),
}

@industries = NEO4J.query(<<~CYPHER)
MATCH (c:Company)<-[:WORKS_AT {current: true}]-(l:BusinessLeader)
RETURN c.industry AS industry, count(DISTINCT l) AS leader_count
ORDER BY leader_count DESC
CYPHER
end

# GET /network/top_connectors
# Business leaders with the most connections — the key "bridge nodes" for
# reaching new prospects (the warm-intro strategy described in the article)
def top_connectors
@connectors = NEO4J.query(<<~CYPHER)
MATCH (l:BusinessLeader)-[:KNOWS]-(other)
WITH l, count(DISTINCT other) AS degree
OPTIONAL MATCH (l)-[:WORKS_AT {current: true}]->(c:Company)
RETURN l.id AS id, l.name AS name, l.title AS title,
c.name AS company_name, l.influence_score AS influence_score,
degree
ORDER BY degree DESC
LIMIT 20
CYPHER
end

# GET /network/by_industry?industry=FinTech
# All leaders in a given industry with their connection counts —
# the primary B2B targeting view
def by_industry
@industry = params[:industry]
return redirect_to network_path unless @industry.present?

@leaders = NEO4J.query(<<~CYPHER, industry: @industry)
MATCH (l:BusinessLeader)-[:WORKS_AT {current: true}]->(c:Company {industry: $industry})
OPTIONAL MATCH (l)-[:KNOWS]-(other)
WITH l, c, count(DISTINCT other) AS connections
RETURN l.id AS id, l.name AS name, l.title AS title,
c.name AS company_name, l.influence_score AS influence_score,
connections
ORDER BY connections DESC, l.influence_score DESC
CYPHER

@industries = NEO4J.query(<<~CYPHER)
MATCH (c:Company)
RETURN DISTINCT c.industry AS industry ORDER BY industry
CYPHER
end
end
Loading