mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
Repository Structure:
- Move files from cluttered root directory into organized structure
- Create archive/ for archived data and scraper results
- Create bugulma/ for the complete application (frontend + backend)
- Create data/ for sample datasets and reference materials
- Create docs/ for comprehensive documentation structure
- Create scripts/ for utility scripts and API tools
Backend Implementation:
- Implement 3 missing backend endpoints identified in gap analysis:
* GET /api/v1/organizations/{id}/matching/direct - Direct symbiosis matches
* GET /api/v1/users/me/organizations - User organizations
* POST /api/v1/proposals/{id}/status - Update proposal status
- Add complete proposal domain model, repository, and service layers
- Create database migration for proposals table
- Fix CLI server command registration issue
API Documentation:
- Add comprehensive proposals.md API documentation
- Update README.md with Users and Proposals API sections
- Document all request/response formats, error codes, and business rules
Code Quality:
- Follow existing Go backend architecture patterns
- Add proper error handling and validation
- Match frontend expected response schemas
- Maintain clean separation of concerns (handler -> service -> repository)
409 lines
11 KiB
Go
409 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"bugulma/backend/internal/domain"
|
|
"bugulma/backend/internal/repository"
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
|
|
)
|
|
|
|
// GraphHandler handles graph database queries and relationship exploration
|
|
type GraphHandler struct {
|
|
driver neo4j.DriverWithContext
|
|
database string
|
|
}
|
|
|
|
// NewGraphHandler creates a new graph handler
|
|
func NewGraphHandler(driver neo4j.DriverWithContext, database string) *GraphHandler {
|
|
return &GraphHandler{
|
|
driver: driver,
|
|
database: database,
|
|
}
|
|
}
|
|
|
|
// GetOrganizationNetwork retrieves the network of organizations connected through resource flows
|
|
// @Summary Get organization network
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Param organizationId path string true "Organization ID"
|
|
// @Param depth query int false "Depth of traversal" default(2)
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/graph/organizations/{organizationId}/network [get]
|
|
func (h *GraphHandler) GetOrganizationNetwork(c *gin.Context) {
|
|
orgID := c.Param("organizationId")
|
|
depth := c.DefaultQuery("depth", "2")
|
|
|
|
session := h.driver.NewSession(c.Request.Context(), neo4j.SessionConfig{
|
|
AccessMode: neo4j.AccessModeRead,
|
|
DatabaseName: h.database,
|
|
})
|
|
defer session.Close(c.Request.Context())
|
|
|
|
result, err := session.ExecuteRead(c.Request.Context(), func(tx neo4j.ManagedTransaction) (interface{}, error) {
|
|
cypher := `
|
|
MATCH path = (o:Organization {id: $org_id})-[*1..` + depth + `]-(connected)
|
|
WHERE connected:Organization OR connected:Site OR connected:ResourceFlow
|
|
RETURN path
|
|
LIMIT 100
|
|
`
|
|
|
|
queryResult, err := tx.Run(c.Request.Context(), cypher, map[string]interface{}{
|
|
"org_id": orgID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var paths []interface{}
|
|
for queryResult.Next(c.Request.Context()) {
|
|
record := queryResult.Record()
|
|
if path, ok := record.Get("path"); ok {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
|
|
// Convert Neo4j paths to standard graph format
|
|
return PathToGraphData(paths), nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
graphData := result.(*GraphData)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"organization_id": orgID,
|
|
"depth": depth,
|
|
"graph": graphData,
|
|
"node_count": len(graphData.Nodes),
|
|
"edge_count": len(graphData.Edges),
|
|
})
|
|
}
|
|
|
|
// FindShortestPath finds the shortest path between two organizations
|
|
// @Summary Find shortest path between organizations
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Param sourceId query string true "Source Organization ID"
|
|
// @Param targetId query string true "Target Organization ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/graph/shortest-path [get]
|
|
func (h *GraphHandler) FindShortestPath(c *gin.Context) {
|
|
sourceID := c.Query("sourceId")
|
|
targetID := c.Query("targetId")
|
|
|
|
if sourceID == "" || targetID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sourceId and targetId are required"})
|
|
return
|
|
}
|
|
|
|
session := h.driver.NewSession(c.Request.Context(), neo4j.SessionConfig{
|
|
AccessMode: neo4j.AccessModeRead,
|
|
DatabaseName: h.database,
|
|
})
|
|
defer session.Close(c.Request.Context())
|
|
|
|
result, err := session.ExecuteRead(c.Request.Context(), func(tx neo4j.ManagedTransaction) (interface{}, error) {
|
|
cypher := `
|
|
MATCH (source:Organization {id: $source_id}), (target:Organization {id: $target_id})
|
|
MATCH path = shortestPath((source)-[*]-(target))
|
|
RETURN path, length(path) as pathLength
|
|
`
|
|
|
|
queryResult, err := tx.Run(c.Request.Context(), cypher, map[string]interface{}{
|
|
"source_id": sourceID,
|
|
"target_id": targetID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if queryResult.Next(c.Request.Context()) {
|
|
record := queryResult.Record()
|
|
pathInterface, _ := record.Get("path")
|
|
pathLength, _ := record.Get("pathLength")
|
|
|
|
// Convert path to graph data
|
|
var paths []interface{}
|
|
if pathInterface != nil {
|
|
paths = append(paths, pathInterface)
|
|
}
|
|
graphData := PathToGraphData(paths)
|
|
|
|
return gin.H{
|
|
"found": true,
|
|
"length": pathLength,
|
|
"graph": graphData,
|
|
"node_count": len(graphData.Nodes),
|
|
"edge_count": len(graphData.Edges),
|
|
}, nil
|
|
}
|
|
|
|
return gin.H{
|
|
"found": false,
|
|
"length": -1,
|
|
"graph": &GraphData{Nodes: []GraphNode{}, Edges: []GraphEdge{}},
|
|
}, nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// GetSpatialProximity finds organizations and sites near a location
|
|
// @Summary Get spatially proximate entities
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Param lat query float64 true "Latitude"
|
|
// @Param lng query float64 true "Longitude"
|
|
// @Param radius query float64 false "Radius in km" default(5.0)
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/graph/spatial-proximity [get]
|
|
func (h *GraphHandler) GetSpatialProximity(c *gin.Context) {
|
|
lat := c.Query("lat")
|
|
lng := c.Query("lng")
|
|
radius := c.DefaultQuery("radius", "5.0")
|
|
|
|
if lat == "" || lng == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "lat and lng are required"})
|
|
return
|
|
}
|
|
|
|
session := h.driver.NewSession(c.Request.Context(), neo4j.SessionConfig{
|
|
AccessMode: neo4j.AccessModeRead,
|
|
DatabaseName: h.database,
|
|
})
|
|
defer session.Close(c.Request.Context())
|
|
|
|
result, err := session.ExecuteRead(c.Request.Context(), func(tx neo4j.ManagedTransaction) (interface{}, error) {
|
|
cypher := `
|
|
MATCH (s:Site)
|
|
WHERE s.latitude IS NOT NULL AND s.longitude IS NOT NULL
|
|
WITH s, point.distance(
|
|
point({latitude: toFloat($lat), longitude: toFloat($lng)}),
|
|
point({latitude: s.latitude, longitude: s.longitude})
|
|
) / 1000.0 AS distance_km
|
|
WHERE distance_km <= toFloat($radius)
|
|
OPTIONAL MATCH (o:Organization)-[:OPERATES_AT]->(s)
|
|
RETURN s.id as site_id, s.name as site_name,
|
|
collect(DISTINCT {id: o.id, name: o.name}) as organizations,
|
|
distance_km
|
|
ORDER BY distance_km
|
|
LIMIT 50
|
|
`
|
|
|
|
queryResult, err := tx.Run(c.Request.Context(), cypher, map[string]interface{}{
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius": radius,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []map[string]interface{}
|
|
for queryResult.Next(c.Request.Context()) {
|
|
record := queryResult.Record()
|
|
results = append(results, map[string]interface{}{
|
|
"site_id": record.Values[0],
|
|
"site_name": record.Values[1],
|
|
"organizations": record.Values[2],
|
|
"distance_km": record.Values[3],
|
|
})
|
|
}
|
|
|
|
return results, nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"center": gin.H{
|
|
"lat": lat,
|
|
"lng": lng,
|
|
},
|
|
"radius_km": radius,
|
|
"results": result,
|
|
})
|
|
}
|
|
|
|
// GetMatchingOpportunities finds potential symbiosis opportunities using graph patterns
|
|
// @Summary Get matching opportunities
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Param resourceType query string false "Resource type filter"
|
|
// @Success 200 {array} map[string]interface{}
|
|
// @Router /api/graph/matching-opportunities [get]
|
|
func (h *GraphHandler) GetMatchingOpportunities(c *gin.Context) {
|
|
resourceType := c.Query("resourceType")
|
|
|
|
session := h.driver.NewSession(c.Request.Context(), neo4j.SessionConfig{
|
|
AccessMode: neo4j.AccessModeRead,
|
|
DatabaseName: h.database,
|
|
})
|
|
defer session.Close(c.Request.Context())
|
|
|
|
result, err := session.ExecuteRead(c.Request.Context(), func(tx neo4j.ManagedTransaction) (interface{}, error) {
|
|
cypher := `
|
|
MATCH (output:ResourceFlow {direction: 'output'})
|
|
MATCH (input:ResourceFlow {direction: 'input'})
|
|
WHERE output.type = input.type
|
|
`
|
|
|
|
if resourceType != "" {
|
|
cypher += ` AND output.type = $resource_type`
|
|
}
|
|
|
|
cypher += `
|
|
OPTIONAL MATCH (output)<-[:HAS_FLOW]-(outputOrg:Organization)
|
|
OPTIONAL MATCH (input)<-[:HAS_FLOW]-(inputOrg:Organization)
|
|
WHERE outputOrg.id <> inputOrg.id
|
|
RETURN
|
|
output.id as output_flow_id,
|
|
input.id as input_flow_id,
|
|
output.type as resource_type,
|
|
outputOrg.id as source_org_id,
|
|
outputOrg.name as source_org_name,
|
|
inputOrg.id as target_org_id,
|
|
inputOrg.name as target_org_name
|
|
LIMIT 100
|
|
`
|
|
|
|
params := map[string]interface{}{}
|
|
if resourceType != "" {
|
|
params["resource_type"] = resourceType
|
|
}
|
|
|
|
queryResult, err := tx.Run(c.Request.Context(), cypher, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var opportunities []map[string]interface{}
|
|
for queryResult.Next(c.Request.Context()) {
|
|
record := queryResult.Record()
|
|
opportunities = append(opportunities, map[string]interface{}{
|
|
"output_flow_id": record.Values[0],
|
|
"input_flow_id": record.Values[1],
|
|
"resource_type": record.Values[2],
|
|
"source_org_id": record.Values[3],
|
|
"source_org_name": record.Values[4],
|
|
"target_org_id": record.Values[5],
|
|
"target_org_name": record.Values[6],
|
|
})
|
|
}
|
|
|
|
return opportunities, nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// SyncGraphDatabase triggers a full sync from PostgreSQL to Neo4j
|
|
// @Summary Sync graph database
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/graph/sync [post]
|
|
func (h *GraphHandler) SyncGraphDatabase(c *gin.Context) {
|
|
// This would trigger the sync service
|
|
// For now, return a placeholder
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "sync_triggered",
|
|
"message": "Graph database sync initiated",
|
|
})
|
|
}
|
|
|
|
// GetGraphStatistics returns graph database statistics
|
|
// @Summary Get graph statistics
|
|
// @Tags graph
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/graph/statistics [get]
|
|
func (h *GraphHandler) GetGraphStatistics(c *gin.Context) {
|
|
session := h.driver.NewSession(c.Request.Context(), neo4j.SessionConfig{
|
|
AccessMode: neo4j.AccessModeRead,
|
|
DatabaseName: h.database,
|
|
})
|
|
defer session.Close(c.Request.Context())
|
|
|
|
result, err := session.ExecuteRead(c.Request.Context(), func(tx neo4j.ManagedTransaction) (interface{}, error) {
|
|
// Get node counts
|
|
nodeCypher := `
|
|
MATCH (n)
|
|
RETURN labels(n)[0] as label, count(*) as count
|
|
`
|
|
|
|
nodeResult, err := tx.Run(c.Request.Context(), nodeCypher, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodeCounts := make(map[string]int)
|
|
for nodeResult.Next(c.Request.Context()) {
|
|
record := nodeResult.Record()
|
|
if label, ok := record.Get("label"); ok {
|
|
if count, ok := record.Get("count"); ok {
|
|
nodeCounts[label.(string)] = int(count.(int64))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get relationship counts
|
|
relCypher := `
|
|
MATCH ()-[r]->()
|
|
RETURN type(r) as rel_type, count(*) as count
|
|
`
|
|
|
|
relResult, err := tx.Run(c.Request.Context(), relCypher, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
relCounts := make(map[string]int)
|
|
for relResult.Next(c.Request.Context()) {
|
|
record := relResult.Record()
|
|
if relType, ok := record.Get("rel_type"); ok {
|
|
if count, ok := record.Get("count"); ok {
|
|
relCounts[relType.(string)] = int(count.(int64))
|
|
}
|
|
}
|
|
}
|
|
|
|
return gin.H{
|
|
"nodes": nodeCounts,
|
|
"relationships": relCounts,
|
|
}, nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// Helper function to sync a single organization to graph
|
|
func syncOrganizationToGraph(ctx context.Context, driver neo4j.DriverWithContext, database string, org *domain.Organization) error {
|
|
repo := repository.NewGraphOrganizationRepository(driver, database)
|
|
return repo.SyncToGraph(ctx, org)
|
|
}
|