turash/bugulma/backend/internal/handler/organization_handler.go
Damir Mukimov 000eab4740
Major repository reorganization and missing backend endpoints implementation
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)
2025-11-25 06:01:16 +01:00

739 lines
22 KiB
Go

package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/matching"
"bugulma/backend/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
)
type OrganizationHandler struct {
orgService *service.OrganizationService
imageService *service.ImageService
resourceFlowService *service.ResourceFlowService
matchingService *matching.Service
}
func NewOrganizationHandler(orgService *service.OrganizationService, imageService *service.ImageService, resourceFlowService *service.ResourceFlowService, matchingService *matching.Service) *OrganizationHandler {
return &OrganizationHandler{
orgService: orgService,
imageService: imageService,
resourceFlowService: resourceFlowService,
matchingService: matchingService,
}
}
type CreateOrganizationRequest struct {
// Required fields
Name string `json:"name" binding:"required"`
Sector string `json:"sector" binding:"required"`
Subtype string `json:"subtype" binding:"required"`
// Basic information
Description string `json:"description"`
LogoURL string `json:"logoUrl"`
GalleryImages []string `json:"galleryImages"`
Website string `json:"website"`
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
// Business-specific fields
LegalForm string `json:"legalForm"`
PrimaryContactEmail string `json:"primaryContactEmail"`
PrimaryContactPhone string `json:"primaryContactPhone"`
IndustrialSector string `json:"industrialSector"`
CompanySize int `json:"companySize"`
YearsOperation int `json:"yearsOperation"`
SupplyChainRole string `json:"supplyChainRole"`
Certifications []string `json:"certifications"`
BusinessFocus []string `json:"businessFocus"`
StrategicVision string `json:"strategicVision"`
DriversBarriers string `json:"driversBarriers"`
ReadinessMaturity *int `json:"readinessMaturity"` // 1-5
TrustScore *float64 `json:"trustScore"` // 0.0-1.0
// Technical capabilities
TechnicalExpertise []string `json:"technicalExpertise"`
AvailableTechnology []string `json:"availableTechnology"`
ManagementSystems []string `json:"managementSystems"`
// Products and Services
SellsProducts []domain.ProductJSON `json:"sellsProducts"`
OffersServices []domain.ServiceJSON `json:"offersServices"`
NeedsServices []domain.ServiceNeedJSON `json:"needsServices"`
// Cultural/Historical building fields
YearBuilt string `json:"yearBuilt"`
BuilderOwner string `json:"builderOwner"`
Architect string `json:"architect"`
OriginalPurpose string `json:"originalPurpose"`
CurrentUse string `json:"currentUse"`
Style string `json:"style"`
Materials string `json:"materials"`
Storeys *int `json:"storeys"`
HeritageStatus string `json:"heritageStatus"`
// Metadata
Verified bool `json:"verified"`
Notes string `json:"notes"`
Sources []string `json:"sources"`
// Relationships
TrustNetwork []string `json:"trustNetwork"`
ExistingSymbioticRelationships []string `json:"existingSymbioticRelationships"`
}
func (h *OrganizationHandler) Create(c *gin.Context) {
var req CreateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
orgReq := service.CreateOrganizationRequest{
Name: req.Name,
Subtype: domain.OrganizationSubtype(req.Subtype),
Sector: req.Sector,
Description: req.Description,
LogoURL: req.LogoURL,
GalleryImages: req.GalleryImages,
Website: req.Website,
Address: req.Address,
Latitude: req.Latitude,
Longitude: req.Longitude,
LegalForm: req.LegalForm,
PrimaryContactEmail: req.PrimaryContactEmail,
PrimaryContactPhone: req.PrimaryContactPhone,
IndustrialSector: req.IndustrialSector,
CompanySize: req.CompanySize,
YearsOperation: req.YearsOperation,
SupplyChainRole: domain.SupplyChainRole(req.SupplyChainRole),
Certifications: req.Certifications,
BusinessFocus: req.BusinessFocus,
StrategicVision: req.StrategicVision,
DriversBarriers: req.DriversBarriers,
ReadinessMaturity: req.ReadinessMaturity,
TrustScore: req.TrustScore,
TechnicalExpertise: req.TechnicalExpertise,
AvailableTechnology: req.AvailableTechnology,
ManagementSystems: req.ManagementSystems,
SellsProducts: req.SellsProducts,
OffersServices: req.OffersServices,
NeedsServices: req.NeedsServices,
YearBuilt: req.YearBuilt,
BuilderOwner: req.BuilderOwner,
Architect: req.Architect,
OriginalPurpose: req.OriginalPurpose,
CurrentUse: req.CurrentUse,
Style: req.Style,
Materials: req.Materials,
Storeys: req.Storeys,
HeritageStatus: req.HeritageStatus,
Verified: req.Verified,
Notes: req.Notes,
Sources: req.Sources,
TrustNetwork: req.TrustNetwork,
ExistingSymbioticRelationships: req.ExistingSymbioticRelationships,
}
org, err := h.orgService.Create(c.Request.Context(), orgReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, org)
}
func (h *OrganizationHandler) GetByID(c *gin.Context) {
id := c.Param("id")
org, err := h.orgService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
c.JSON(http.StatusOK, org)
}
func (h *OrganizationHandler) GetAll(c *gin.Context) {
// Check for sector filter
sector := c.Query("sector")
if sector != "" {
orgs, err := h.orgService.GetBySector(c.Request.Context(), sector)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
return
}
// No filter - return all organizations
orgs, err := h.orgService.GetAll(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
}
func (h *OrganizationHandler) Update(c *gin.Context) {
id := c.Param("id")
var req CreateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
org, err := h.orgService.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
// Update fields
org.Name = req.Name
org.Sector = req.Sector
org.Subtype = domain.OrganizationSubtype(req.Subtype)
org.Description = req.Description
org.LogoURL = req.LogoURL
org.Website = req.Website
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, org)
}
func (h *OrganizationHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.orgService.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *OrganizationHandler) GetBySubtype(c *gin.Context) {
subtype := c.Param("subtype")
orgs, err := h.orgService.GetBySubtype(c.Request.Context(), domain.OrganizationSubtype(subtype))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
}
func (h *OrganizationHandler) GetBySector(c *gin.Context) {
sector := c.Param("sector")
orgs, err := h.orgService.GetBySector(c.Request.Context(), sector)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
}
// GetSectorStats returns sector statistics (top sectors by organization count)
func (h *OrganizationHandler) GetSectorStats(c *gin.Context) { // force reload
// Get limit from query param, default to 10
limitStr := c.DefaultQuery("limit", "10")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 || limit > 50 {
limit = 10
}
stats, err := h.orgService.GetSectorStats(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sectors": stats})
}
func (h *OrganizationHandler) GetByCertification(c *gin.Context) {
cert := c.Param("cert")
orgs, err := h.orgService.GetByCertification(c.Request.Context(), cert)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
}
// Search handles search requests for organizations
// Query parameters:
// - q: search query string (required)
// - limit: maximum number of results (optional, default: 50, max: 200)
func (h *OrganizationHandler) Search(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit := 50 // Default limit
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 200 {
limit = 200 // Maximum limit
}
}
}
orgs, err := h.orgService.Search(c.Request.Context(), query, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return structured response matching frontend schema
response := gin.H{
"organizations": orgs,
"count": len(orgs),
"total": len(orgs), // For now, we don't have total count from search
}
c.JSON(http.StatusOK, response)
}
// SearchSuggestions handles autocomplete/suggestion requests
// Query parameters:
// - q: search query string (required)
// - limit: maximum number of suggestions (optional, default: 10, max: 50)
func (h *OrganizationHandler) SearchSuggestions(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []string{}) // Return empty array for empty query
return
}
limit := 10 // Default limit
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 50 {
limit = 50 // Maximum limit
}
}
}
suggestions, err := h.orgService.SearchSuggestions(c.Request.Context(), query, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, suggestions)
}
func (h *OrganizationHandler) GetNearby(c *gin.Context) {
var query struct {
Latitude float64 `form:"lat" binding:"required"`
Longitude float64 `form:"lng" binding:"required"`
RadiusKm float64 `form:"radius" binding:"required"`
}
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
orgs, err := h.orgService.GetWithinRadius(c.Request.Context(), query.Latitude, query.Longitude, query.RadiusKm)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, orgs)
}
// UploadLogo handles logo image uploads for organizations
func (h *OrganizationHandler) UploadLogo(c *gin.Context) {
orgID := c.Param("id")
// Get the uploaded file
file, header, err := c.Request.FormFile("logo")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No logo file provided"})
return
}
defer file.Close()
// Save the image
uploadedImage, err := h.imageService.SaveImage(file, header, "logos")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save logo: %v", err)})
return
}
// Update the organization with the new logo URL
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
// Delete old logo if it exists
if org.LogoURL != "" {
h.imageService.DeleteImage(org.LogoURL)
}
org.LogoURL = uploadedImage.URL
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update organization"})
return
}
c.JSON(http.StatusOK, gin.H{
"url": uploadedImage.URL,
"message": "Logo uploaded successfully",
})
}
// UploadGalleryImage handles gallery image uploads for organizations
func (h *OrganizationHandler) UploadGalleryImage(c *gin.Context) {
orgID := c.Param("id")
// Get the uploaded file
file, header, err := c.Request.FormFile("image")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No image file provided"})
return
}
defer file.Close()
// Save the image
uploadedImage, err := h.imageService.SaveImage(file, header, "gallery")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save image: %v", err)})
return
}
// Update the organization by adding the image to gallery
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
// Get current gallery images
galleryImages := []string{}
if len(org.GalleryImages) > 0 {
// Parse the JSON field
var currentImages []string
if err := json.Unmarshal(org.GalleryImages, &currentImages); err == nil {
galleryImages = currentImages
}
}
// Add new image
galleryImages = append(galleryImages, uploadedImage.URL)
// Convert to JSON for storage
galleryJSON, _ := json.Marshal(galleryImages)
org.GalleryImages = datatypes.JSON(galleryJSON)
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update organization"})
return
}
c.JSON(http.StatusOK, gin.H{
"url": uploadedImage.URL,
"galleryImages": galleryImages,
"message": "Gallery image uploaded successfully",
})
}
// DeleteGalleryImage removes a gallery image from an organization
func (h *OrganizationHandler) DeleteGalleryImage(c *gin.Context) {
orgID := c.Param("id")
imageURL := c.Query("url")
if imageURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Image URL required"})
return
}
// Get the organization
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
// Remove the image from gallery
galleryImages := []string{}
if len(org.GalleryImages) > 0 {
// Parse the JSON field
var currentImages []string
if err := json.Unmarshal(org.GalleryImages, &currentImages); err == nil {
for _, img := range currentImages {
if img != imageURL {
galleryImages = append(galleryImages, img)
}
}
}
}
// Delete the file from disk
if err := h.imageService.DeleteImage(imageURL); err != nil {
// Log error but continue with database update
fmt.Printf("Warning: failed to delete image file: %v\n", err)
}
// Convert to JSON for storage
galleryJSON, _ := json.Marshal(galleryImages)
org.GalleryImages = datatypes.JSON(galleryJSON)
if err := h.orgService.Update(c.Request.Context(), org); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update organization"})
return
}
c.JSON(http.StatusOK, gin.H{
"galleryImages": galleryImages,
"message": "Gallery image deleted successfully",
})
}
// GetSimilarOrganizations returns organizations similar to the given organization
func (h *OrganizationHandler) GetSimilarOrganizations(c *gin.Context) {
orgID := c.Param("id")
limitStr := c.DefaultQuery("limit", "5")
limit := 5
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
}
// For now, return organizations of the same sector/type
// TODO: Implement more sophisticated similarity algorithm
org, err := h.orgService.GetByID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"})
return
}
// Get organizations by sector
similarOrgs, err := h.orgService.GetBySector(c.Request.Context(), org.Sector)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get similar organizations"})
return
}
// Filter out the original organization and limit results
var result []*domain.Organization
for _, similarOrg := range similarOrgs {
if similarOrg.ID != orgID && len(result) < limit {
result = append(result, similarOrg)
}
}
c.JSON(http.StatusOK, result)
}
// GetOrganizationProposals returns proposals related to the organization
func (h *OrganizationHandler) GetOrganizationProposals(c *gin.Context) {
// orgID := c.Param("id") // TODO: Use when implementing proposals functionality
// TODO: Implement proposals functionality
// For now, return empty array
c.JSON(http.StatusOK, []interface{}{})
}
// GetOrganizationResources returns resource flows for the organization
func (h *OrganizationHandler) GetOrganizationResources(c *gin.Context) {
orgID := c.Param("id")
// Get resource flows by organization ID
resourceFlows, err := h.resourceFlowService.GetByOrganizationID(c.Request.Context(), orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get organization resources"})
return
}
c.JSON(http.StatusOK, resourceFlows)
}
// DirectSymbiosisMatch represents a direct symbiosis match
type DirectSymbiosisMatch struct {
PartnerID string `json:"partner_id"`
PartnerName string `json:"partner_name"`
Resource string `json:"resource"`
ResourceFlowID string `json:"resource_flow_id"`
}
// DirectSymbiosisResponse represents the response for direct symbiosis matches
type DirectSymbiosisResponse struct {
Providers []DirectSymbiosisMatch `json:"providers"`
Consumers []DirectSymbiosisMatch `json:"consumers"`
}
// GetDirectMatches returns direct matches for the organization
func (h *OrganizationHandler) GetDirectMatches(c *gin.Context) {
orgID := c.Param("id")
if orgID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Organization ID is required"})
return
}
ctx := c.Request.Context()
// Get organization's resource flows
resourceFlows, err := h.resourceFlowService.GetByOrganizationID(ctx, orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get resource flows: %v", err)})
return
}
var providers []DirectSymbiosisMatch
var consumers []DirectSymbiosisMatch
// Separate flows by direction
var inputFlows []*domain.ResourceFlow // What this org needs (consumes)
var outputFlows []*domain.ResourceFlow // What this org provides (produces)
for _, flow := range resourceFlows {
if flow.Direction == domain.DirectionInput {
inputFlows = append(inputFlows, flow)
} else if flow.Direction == domain.DirectionOutput {
outputFlows = append(outputFlows, flow)
}
}
// Find providers: organizations that can provide what this org needs
for _, inputFlow := range inputFlows {
// Find organizations that have output flows of the same type
matchingOutputs, err := h.resourceFlowService.GetByTypeAndDirection(ctx, inputFlow.Type, domain.DirectionOutput)
if err != nil {
continue
}
for _, outputFlow := range matchingOutputs {
// Skip if it's the same organization
if outputFlow.OrganizationID == orgID {
continue
}
// Get organization info
org, err := h.orgService.GetByID(ctx, outputFlow.OrganizationID)
if err != nil {
continue
}
providers = append(providers, DirectSymbiosisMatch{
PartnerID: outputFlow.OrganizationID,
PartnerName: org.Name,
Resource: string(outputFlow.Type),
ResourceFlowID: outputFlow.ID,
})
}
}
// Find consumers: organizations that need what this org provides
for _, outputFlow := range outputFlows {
// Find organizations that have input flows of the same type
matchingInputs, err := h.resourceFlowService.GetByTypeAndDirection(ctx, outputFlow.Type, domain.DirectionInput)
if err != nil {
continue
}
for _, inputFlow := range matchingInputs {
// Skip if it's the same organization
if inputFlow.OrganizationID == orgID {
continue
}
// Get organization info
org, err := h.orgService.GetByID(ctx, inputFlow.OrganizationID)
if err != nil {
continue
}
consumers = append(consumers, DirectSymbiosisMatch{
PartnerID: inputFlow.OrganizationID,
PartnerName: org.Name,
Resource: string(inputFlow.Type),
ResourceFlowID: inputFlow.ID,
})
}
}
// Remove duplicates and limit results
providers = h.deduplicateMatches(providers, 10)
consumers = h.deduplicateMatches(consumers, 10)
response := DirectSymbiosisResponse{
Providers: providers,
Consumers: consumers,
}
c.JSON(http.StatusOK, response)
}
// deduplicateMatches removes duplicate matches and limits the number of results
func (h *OrganizationHandler) deduplicateMatches(matches []DirectSymbiosisMatch, limit int) []DirectSymbiosisMatch {
seen := make(map[string]bool)
var result []DirectSymbiosisMatch
for _, match := range matches {
key := match.PartnerID + ":" + match.Resource
if !seen[key] && len(result) < limit {
seen[key] = true
result = append(result, match)
}
}
return result
}
// GetUserOrganizations returns organizations associated with the current user
// For now, returns all organizations (can be enhanced with user-organization relationships later)
func (h *OrganizationHandler) GetUserOrganizations(c *gin.Context) {
ctx := c.Request.Context()
// Get user ID from context (set by middleware)
_, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// TODO: In future, implement user-organization relationship table
// For now, return all organizations as a temporary solution
// This allows the frontend to work while we develop proper user-org relationships
organizations, err := h.orgService.GetAll(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get organizations: %v", err)})
return
}
c.JSON(http.StatusOK, organizations)
}