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)
274 lines
8.8 KiB
Go
274 lines
8.8 KiB
Go
package domain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SiteType represents the primary usage type of a site
|
|
type SiteType string
|
|
|
|
const (
|
|
SiteTypeIndustrial SiteType = "industrial"
|
|
SiteTypeOffice SiteType = "office"
|
|
SiteTypeWarehouse SiteType = "warehouse"
|
|
SiteTypeRetail SiteType = "retail"
|
|
SiteTypeMixed SiteType = "mixed"
|
|
)
|
|
|
|
// Ownership represents the ownership status
|
|
type Ownership string
|
|
|
|
const (
|
|
OwnershipOwned Ownership = "owned"
|
|
OwnershipLeased Ownership = "leased"
|
|
OwnershipShared Ownership = "shared"
|
|
)
|
|
|
|
// Site represents a physical location/building where business activities occur
|
|
type Site struct {
|
|
ID string `gorm:"primaryKey;type:text"`
|
|
Name string `gorm:"type:text;index"` // Primary name (Russian)
|
|
|
|
// Location
|
|
Latitude float64 `gorm:"type:double precision;not null;index:idx_site_location"`
|
|
Longitude float64 `gorm:"type:double precision;not null;index:idx_site_location"`
|
|
|
|
// PostGIS geometry field (location_geometry) is managed separately via migrations
|
|
// It's excluded from GORM operations using raw SQL queries only
|
|
// The column exists in the database but is not part of the GORM model
|
|
|
|
// Site characteristics
|
|
SiteType SiteType `gorm:"type:varchar(50);index"`
|
|
FloorAreaM2 float64 `gorm:"type:double precision"`
|
|
Ownership Ownership `gorm:"type:varchar(50)"`
|
|
|
|
// Ownership and operations
|
|
OwnerOrganizationID string `gorm:"type:text;index"`
|
|
|
|
// Infrastructure
|
|
AvailableUtilities datatypes.JSON `gorm:"default:'[]'"` // []string - electricity, gas, water, wastewater, heating, cooling
|
|
ParkingSpaces int `gorm:"type:integer"`
|
|
LoadingDocks int `gorm:"type:integer"`
|
|
CraneCapacityTonnes float64 `gorm:"type:double precision"`
|
|
|
|
// Environmental
|
|
EnergyRating string `gorm:"type:text"`
|
|
WasteManagement datatypes.JSON `gorm:"default:'[]'"` // []string
|
|
EnvironmentalImpact string `gorm:"type:text"`
|
|
|
|
// Historical/architectural (for heritage sites)
|
|
YearBuilt string `gorm:"type:text"`
|
|
BuilderOwner string `gorm:"type:text"`
|
|
Architect string `gorm:"type:text"`
|
|
OriginalPurpose string `gorm:"type:text"`
|
|
CurrentUse string `gorm:"type:text"`
|
|
Style string `gorm:"type:text"`
|
|
Materials string `gorm:"type:text"`
|
|
Storeys int `gorm:"type:integer"`
|
|
HeritageStatus string `gorm:"type:text"`
|
|
|
|
// Metadata
|
|
Notes string `gorm:"type:text"`
|
|
Sources datatypes.JSON `gorm:"type:jsonb"`
|
|
|
|
// Timestamps
|
|
CreatedAt time.Time `gorm:"autoCreateTime;index"`
|
|
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
|
|
// Associations
|
|
Addresses []Address `gorm:"many2many:site_addresses;"`
|
|
OwnerOrganization *Organization `gorm:"foreignKey:OwnerOrganizationID"`
|
|
OperatingOrganizations []*Organization `gorm:"many2many:site_operating_organizations;"`
|
|
ResourceFlows []ResourceFlow `gorm:"foreignKey:SiteID"`
|
|
SharedAssets []SharedAsset `gorm:"foreignKey:SiteID"`
|
|
}
|
|
|
|
// TableName specifies the table name for GORM
|
|
func (Site) TableName() string {
|
|
return "sites"
|
|
}
|
|
|
|
// unmarshalStringSliceSite safely unmarshals datatypes.JSON to []string for Site
|
|
// Uses the same logic as organization but with additional handling for objects
|
|
func unmarshalStringSliceSite(data datatypes.JSON) []string {
|
|
if len(data) == 0 {
|
|
return []string{}
|
|
}
|
|
|
|
// First try to unmarshal as []string
|
|
var result []string
|
|
if err := json.Unmarshal(data, &result); err == nil {
|
|
return result
|
|
}
|
|
|
|
// If that fails, try to unmarshal as any type and handle it
|
|
var anyValue interface{}
|
|
if err := json.Unmarshal(data, &anyValue); err == nil {
|
|
switch v := anyValue.(type) {
|
|
case []interface{}:
|
|
// It's an array of interfaces, convert to strings
|
|
strings := make([]string, len(v))
|
|
for i, item := range v {
|
|
if str, ok := item.(string); ok {
|
|
strings[i] = str
|
|
} else {
|
|
// Convert to string representation
|
|
if bytes, err := json.Marshal(item); err == nil {
|
|
strings[i] = string(bytes)
|
|
} else {
|
|
strings[i] = fmt.Sprintf("%v", item)
|
|
}
|
|
}
|
|
}
|
|
return strings
|
|
case map[string]interface{}:
|
|
// It's an object, convert to JSON string
|
|
if bytes, err := json.Marshal(v); err == nil {
|
|
return []string{string(bytes)}
|
|
}
|
|
case string:
|
|
return []string{v}
|
|
default:
|
|
// Convert to string representation
|
|
if bytes, err := json.Marshal(v); err == nil {
|
|
return []string{string(bytes)}
|
|
}
|
|
}
|
|
}
|
|
|
|
// As last resort, return empty array
|
|
return []string{}
|
|
}
|
|
|
|
// MarshalJSON custom marshaling to ensure arrays are always arrays
|
|
func (s Site) MarshalJSON() ([]byte, error) {
|
|
type Alias Site
|
|
return json.Marshal(&struct {
|
|
AvailableUtilities []string `json:"AvailableUtilities"`
|
|
WasteManagement []string `json:"WasteManagement"`
|
|
Sources []string `json:"Sources"`
|
|
*Alias
|
|
}{
|
|
AvailableUtilities: unmarshalStringSliceSite(s.AvailableUtilities),
|
|
WasteManagement: unmarshalStringSliceSite(s.WasteManagement),
|
|
Sources: unmarshalStringSliceSite(s.Sources),
|
|
Alias: (*Alias)(&s),
|
|
})
|
|
}
|
|
|
|
// PrimaryAddress returns the primary site address formatted string
|
|
func (s *Site) PrimaryAddress() string {
|
|
if len(s.Addresses) > 0 {
|
|
if s.Addresses[0].FormattedRu != "" {
|
|
return s.Addresses[0].FormattedRu
|
|
}
|
|
if s.Addresses[0].FormattedEn != "" {
|
|
return s.Addresses[0].FormattedEn
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AfterCreate hook to update PostGIS geometry from lat/lng using raw SQL
|
|
// Uses ST_SetSRID and ST_MakePoint for proper PostGIS geometry creation (2025 best practice)
|
|
// This avoids GORM trying to handle the geometry column directly
|
|
func (s *Site) AfterCreate(tx *gorm.DB) error {
|
|
if tx.Dialector.Name() == "postgres" {
|
|
// Check if PostGIS is available and column exists before updating
|
|
var postgisAvailable bool
|
|
var columnExists bool
|
|
|
|
if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err != nil {
|
|
return nil // Silently skip if PostGIS check fails
|
|
}
|
|
|
|
if !postgisAvailable {
|
|
return nil // PostGIS not available, skip geometry update
|
|
}
|
|
|
|
if err := tx.Raw(`
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = 'sites' AND column_name = 'location_geometry'
|
|
)
|
|
`).Scan(&columnExists).Error; err != nil || !columnExists {
|
|
return nil // Column doesn't exist, skip
|
|
}
|
|
|
|
// Update geometry column using proper PostGIS functions (2025 best practice)
|
|
// ST_SetSRID ensures correct SRID, ST_MakePoint creates point from coordinates
|
|
if s.Latitude != 0 && s.Longitude != 0 {
|
|
if err := tx.Exec(`
|
|
UPDATE sites
|
|
SET location_geometry = ST_SetSRID(ST_MakePoint(?, ?), 4326)
|
|
WHERE id = ?
|
|
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
|
|
// Log error but don't fail the transaction
|
|
// Geometry update is non-critical for basic operations
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AfterUpdate hook to update PostGIS geometry from lat/lng using raw SQL
|
|
// Uses ST_SetSRID and ST_MakePoint for proper PostGIS geometry creation (2025 best practice)
|
|
func (s *Site) AfterUpdate(tx *gorm.DB) error {
|
|
if tx.Dialector.Name() == "postgres" {
|
|
// Check if PostGIS is available and column exists before updating
|
|
var postgisAvailable bool
|
|
var columnExists bool
|
|
|
|
if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'postgis')").Scan(&postgisAvailable).Error; err != nil {
|
|
return nil // Silently skip if PostGIS check fails
|
|
}
|
|
|
|
if !postgisAvailable {
|
|
return nil // PostGIS not available, skip geometry update
|
|
}
|
|
|
|
if err := tx.Raw(`
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = 'sites' AND column_name = 'location_geometry'
|
|
)
|
|
`).Scan(&columnExists).Error; err != nil || !columnExists {
|
|
return nil // Column doesn't exist, skip
|
|
}
|
|
|
|
// Update geometry column using proper PostGIS functions (2025 best practice)
|
|
// ST_SetSRID ensures correct SRID, ST_MakePoint creates point from coordinates
|
|
if s.Latitude != 0 && s.Longitude != 0 {
|
|
if err := tx.Exec(`
|
|
UPDATE sites
|
|
SET location_geometry = ST_SetSRID(ST_MakePoint(?, ?), 4326)
|
|
WHERE id = ?
|
|
`, s.Longitude, s.Latitude, s.ID).Error; err != nil {
|
|
// Log error but don't fail the transaction
|
|
// Geometry update is non-critical for basic operations
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type SiteRepository interface {
|
|
Create(ctx context.Context, site *Site) error
|
|
GetByID(ctx context.Context, id string) (*Site, error)
|
|
GetByOrganizationID(ctx context.Context, organizationID string) ([]*Site, error)
|
|
GetWithinRadius(ctx context.Context, lat, lng, radiusKm float64) ([]*Site, error)
|
|
GetAll(ctx context.Context) ([]*Site, error)
|
|
GetBySiteType(ctx context.Context, siteType SiteType) ([]*Site, error)
|
|
GetHeritageSites(ctx context.Context, locale string) ([]*Site, error)
|
|
Update(ctx context.Context, site *Site) error
|
|
Delete(ctx context.Context, id string) error
|
|
}
|