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(?::double precision, ?::double precision), 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(?::double precision, ?::double precision), 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) // Find records by arbitrary where clause (convenience for service code/tests) FindWhere(query interface{}, args ...interface{}) ([]*Site, error) Update(ctx context.Context, site *Site) error Delete(ctx context.Context, id string) error }