feat: Integrate Folk CRM API for investor synchronization and compatibility scoring
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,190 @@
|
||||
from typing import List
|
||||
|
||||
from db.db import get_db
|
||||
from db.models import InvestorTable
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.crm import folk
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
router = APIRouter(prefix="/folk", tags=["Folk CRM"])
|
||||
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SyncInvestorsRequest(BaseModel):
|
||||
investor_ids: List[int]
|
||||
group_id: str
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
investor_id: int
|
||||
investor_name: str
|
||||
company_id: str
|
||||
company_name: str
|
||||
team_members_synced: int
|
||||
person_ids: List[str]
|
||||
|
||||
|
||||
class SyncInvestorsResponse(BaseModel):
|
||||
success: bool
|
||||
synced_count: int
|
||||
results: List[SyncResult]
|
||||
errors: List[dict]
|
||||
|
||||
|
||||
@router.get("/groups", response_model=List[GroupResponse])
|
||||
def get_folk_groups():
|
||||
"""Get all groups from Folk CRM.
|
||||
|
||||
Returns a list of groups with their id and name that can be used
|
||||
to sync investors to Folk.
|
||||
"""
|
||||
try:
|
||||
groups_data = folk.get_groups()
|
||||
items = groups_data.get("data", {}).get("items", [])
|
||||
|
||||
return [GroupResponse(id=item["id"], name=item["name"]) for item in items]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to fetch groups from Folk: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-investors", response_model=SyncInvestorsResponse)
|
||||
def sync_investors_to_folk(
|
||||
request: SyncInvestorsRequest, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Sync investors to Folk CRM as companies with their team members as people.
|
||||
|
||||
Takes a list of investor IDs and a Folk group ID, then:
|
||||
1. Creates each investor as a company in the specified Folk group
|
||||
2. Creates each team member as a person linked to that company
|
||||
|
||||
Args:
|
||||
investor_ids: List of investor IDs from the database
|
||||
group_id: Folk group ID where investors should be added
|
||||
|
||||
Returns:
|
||||
Summary of sync operation including successes and errors
|
||||
"""
|
||||
# Fetch investors with their team members
|
||||
investors = (
|
||||
db.query(InvestorTable)
|
||||
.options(
|
||||
selectinload(InvestorTable.team_members),
|
||||
selectinload(InvestorTable.sectors),
|
||||
)
|
||||
.filter(InvestorTable.id.in_(request.investor_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
if not investors:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No investors found with the provided IDs"
|
||||
)
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for investor in investors:
|
||||
try:
|
||||
# Create company in Folk
|
||||
company_data = folk.create_company(
|
||||
name=investor.name,
|
||||
group_id=request.group_id,
|
||||
website=investor.website,
|
||||
description=investor.description,
|
||||
addresses=[investor.headquarters] if investor.headquarters else None,
|
||||
)
|
||||
|
||||
company_id = company_data.get("data", {}).get("id")
|
||||
if not company_id:
|
||||
errors.append(
|
||||
{
|
||||
"investor_id": investor.id,
|
||||
"investor_name": investor.name,
|
||||
"error": "No company ID returned from Folk API",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Create team members as people
|
||||
person_ids = []
|
||||
team_members_synced = 0
|
||||
|
||||
for member in investor.team_members:
|
||||
try:
|
||||
# Extract first name and last name from full name
|
||||
name_parts = member.name.split(maxsplit=1)
|
||||
first_name = name_parts[0] if name_parts else member.name
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
||||
|
||||
# Build URLs list from source_url if available
|
||||
urls_list = None
|
||||
if hasattr(member, "source_url") and member.source_url:
|
||||
urls_list = [member.source_url]
|
||||
|
||||
# Build job title from title or role
|
||||
job_title = None
|
||||
if hasattr(member, "title") and member.title:
|
||||
job_title = member.title
|
||||
elif hasattr(member, "role") and member.role:
|
||||
job_title = member.role
|
||||
|
||||
person_data = folk.create_person(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=member.email,
|
||||
company_id=company_id,
|
||||
group_id=request.group_id,
|
||||
urls=urls_list,
|
||||
jobTitle=job_title,
|
||||
)
|
||||
|
||||
person_id = person_data.get("data", {}).get("id")
|
||||
if person_id:
|
||||
person_ids.append(person_id)
|
||||
team_members_synced += 1
|
||||
except Exception as person_error:
|
||||
# Log person creation error but continue with other members
|
||||
errors.append(
|
||||
{
|
||||
"investor_id": investor.id,
|
||||
"investor_name": investor.name,
|
||||
"team_member_name": member.name,
|
||||
"error": f"Failed to create person: {str(person_error)}",
|
||||
}
|
||||
)
|
||||
|
||||
results.append(
|
||||
SyncResult(
|
||||
investor_id=investor.id,
|
||||
investor_name=investor.name,
|
||||
company_id=company_id,
|
||||
company_name=company_data.get("data", {}).get(
|
||||
"name", investor.name
|
||||
),
|
||||
team_members_synced=team_members_synced,
|
||||
person_ids=person_ids,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(
|
||||
{
|
||||
"investor_id": investor.id,
|
||||
"investor_name": investor.name,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
|
||||
return SyncInvestorsResponse(
|
||||
success=len(results) > 0,
|
||||
synced_count=len(results),
|
||||
results=results,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Optional
|
||||
|
||||
from db.db import get_db
|
||||
from db.models import FundTable, InvestorTable, SectorTable
|
||||
from db.models import FundTable, InvestorTable, ProjectTable, SectorTable
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from schemas.router_schemas import (
|
||||
@@ -12,6 +12,7 @@ from schemas.router_schemas import (
|
||||
PaginatedResponse,
|
||||
SectorMinimal,
|
||||
)
|
||||
from services.compatibility_score import calculate_project_investor_compatibility
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
router = APIRouter(tags=["Investor Routes"])
|
||||
@@ -46,12 +47,17 @@ class InvestorUpdate(BaseModel):
|
||||
def read_investors(
|
||||
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"),
|
||||
project_id: Optional[int] = Query(
|
||||
None, description="Optional project ID for compatibility scoring"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get all investors with their funds as separate entries (paginated)
|
||||
|
||||
Each investor-fund combination is returned as a separate row.
|
||||
An investor with 3 funds will appear as 3 entries.
|
||||
|
||||
If project_id is provided, calculates compatibility scores for each investor.
|
||||
"""
|
||||
# Calculate offset
|
||||
offset = (page - 1) * page_size
|
||||
@@ -59,6 +65,18 @@ def read_investors(
|
||||
# Get total count
|
||||
total_count = db.query(InvestorTable).count()
|
||||
|
||||
# Load project if project_id provided
|
||||
project = None
|
||||
if project_id is not None:
|
||||
project = (
|
||||
db.query(ProjectTable)
|
||||
.options(selectinload(ProjectTable.sector))
|
||||
.filter(ProjectTable.id == project_id)
|
||||
.first()
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Get paginated results
|
||||
investors = (
|
||||
db.query(InvestorTable)
|
||||
@@ -66,7 +84,8 @@ def read_investors(
|
||||
selectinload(InvestorTable.portfolio_companies),
|
||||
selectinload(InvestorTable.team_members),
|
||||
selectinload(InvestorTable.sectors),
|
||||
selectinload(InvestorTable.funds),
|
||||
selectinload(InvestorTable.funds).selectinload(FundTable.investment_stages),
|
||||
selectinload(InvestorTable.funds).selectinload(FundTable.sectors),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
@@ -76,6 +95,13 @@ def read_investors(
|
||||
# Transform to InvestmentResponse format (one row per investor-fund combination)
|
||||
investment_responses = []
|
||||
for investor in investors:
|
||||
# Calculate compatibility score if project provided
|
||||
compatibility_score = 1.0
|
||||
if project is not None:
|
||||
compatibility_score = calculate_project_investor_compatibility(
|
||||
project=project, investor=investor, use_funds=True
|
||||
)
|
||||
|
||||
# Get top 3 portfolio companies (id and name only)
|
||||
portfolio_companies = [
|
||||
CompanyMinimal(id=company.id, name=company.name)
|
||||
@@ -110,7 +136,7 @@ def read_investors(
|
||||
stage_focus=stage_focus,
|
||||
portfolio_companies=portfolio_companies,
|
||||
sectors=fund_sectors,
|
||||
compatibility_score=1.0,
|
||||
compatibility_score=compatibility_score,
|
||||
)
|
||||
investment_responses.append(investment_response)
|
||||
else:
|
||||
@@ -125,7 +151,7 @@ def read_investors(
|
||||
stage_focus=None,
|
||||
portfolio_companies=portfolio_companies,
|
||||
sectors=[],
|
||||
compatibility_score=1.0,
|
||||
compatibility_score=compatibility_score,
|
||||
)
|
||||
investment_responses.append(investment_response)
|
||||
|
||||
@@ -156,14 +182,31 @@ def filter_investors(
|
||||
max_aum: Optional[int] = Query(None, description="Maximum AUM"),
|
||||
page: int = Query(1, ge=1, description="Page number (starts at 1)"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page (max 100)"),
|
||||
project_id: Optional[int] = Query(
|
||||
None, description="Optional project ID for compatibility scoring"
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Filter investors based on various criteria (paginated)
|
||||
|
||||
Returns investor-fund combinations as separate rows.
|
||||
Queries the funds table to find matching funds.
|
||||
|
||||
If project_id is provided, calculates compatibility scores for each investor.
|
||||
"""
|
||||
|
||||
# Load project if project_id provided
|
||||
project = None
|
||||
if project_id is not None:
|
||||
project = (
|
||||
db.query(ProjectTable)
|
||||
.options(selectinload(ProjectTable.sector))
|
||||
.filter(ProjectTable.id == project_id)
|
||||
.first()
|
||||
)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Start with base query on funds table
|
||||
query = db.query(FundTable).options(
|
||||
selectinload(FundTable.investor).selectinload(
|
||||
@@ -212,6 +255,13 @@ def filter_investors(
|
||||
for fund in funds:
|
||||
investor = fund.investor
|
||||
|
||||
# Calculate compatibility score if project provided
|
||||
compatibility_score = 1.0
|
||||
if project is not None:
|
||||
compatibility_score = calculate_project_investor_compatibility(
|
||||
project=project, investor=investor, use_funds=True
|
||||
)
|
||||
|
||||
# Get top 3 portfolio companies (id and name only)
|
||||
portfolio_companies = [
|
||||
CompanyMinimal(id=company.id, name=company.name)
|
||||
@@ -243,7 +293,7 @@ def filter_investors(
|
||||
stage_focus=stage_focus,
|
||||
portfolio_companies=portfolio_companies,
|
||||
sectors=fund_sectors,
|
||||
compatibility_score=1.0,
|
||||
compatibility_score=compatibility_score,
|
||||
)
|
||||
investment_responses.append(investment_response)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user