package models import ( "strings" "gorm.io/datatypes" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models/concerns" ) // Site represents a customer-facing website or app. One Site can be // hosted on zero or one Server (server_id nullable) and exposes one // or more Deployments (compose services or nginx vhosts) plus zero // or more Repos (via site_repos). The RSMon slice mirrors rstuff's // `sites` table verbatim — see docs/parity/rstuff-inventory.md §2 // and docs/plans/inventory-management.md §4. type Site struct { concerns.Model AccountID int64 `gorm:"type:bigint REFERENCES accounts(id);not null;index" json:"account_id"` Account *Account `json:"-"` ServerID *int64 `gorm:"type:bigint REFERENCES servers(id) ON DELETE SET NULL;index" json:"server_id,omitempty"` Server *Server `json:"-"` ExtID *string `gorm:"size:64" json:"ext_id,omitempty"` Name string `gorm:"size:120;not null" json:"name"` Slug string `gorm:"size:120;not null;index" json:"slug"` URL *string `gorm:"type:text" json:"url,omitempty"` Description *string `gorm:"type:text" json:"description,omitempty"` // Kind is a free-text label (not the PG enum) so we can absorb // rstuff additions without a migration. Default "production". Kind string `gorm:"size:32;not null;default:'production'" json:"kind"` IsActive bool `gorm:"not null;default:true" json:"is_active"` Meta datatypes.JSON `gorm:"type:jsonb;not null;default:'{}'::jsonb" json:"meta"` Deployments []Deployment `gorm:"foreignKey:SiteID" json:"deployments,omitempty"` Repos []Repo `gorm:"many2many:site_repos;" json:"repos,omitempty"` concerns.Timestamped Audited } // TableName returns the table name used for Site. Matches rstuff's // `sites` plural exactly so the parity test stays trivial. func (Site) TableName() string { return "sites" } // SiteSlugify turns a name into a URL-safe slug. Mirrors the rules // in app/models/server.go:Slugify so /sites/:slug looks the same as // /servers/:slug. Consecutive separators collapse to a single dash; // non-ASCII letters are stripped (the same rule as Slugify — we // don't transliterate in v1, see docs/plans/inventory-management.md // §11 for transliteration as a future hardening item). func SiteSlugify(name string) string { slug := strings.ToLower(strings.TrimSpace(name)) var b strings.Builder prevDash := false for _, r := range slug { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false case r == ' ', r == '_', r == '-', r == '.': if !prevDash && b.Len() > 0 { b.WriteByte('-') prevDash = true } } } out := strings.Trim(b.String(), "-") if out == "" { out = "site" } return out } // FindOrCreateSiteBySlug returns the site with the given slug for the // given account, creating an empty row (Name=slug, Kind=production, // IsActive=true) when no match exists. The caller's tx wraps the // operation so docker payload ingestion stays atomic. // See docs/plans/inventory-management.md §7.2 — Docker receiver. func FindOrCreateSiteBySlug(tx *gorm.DB, accountID int64, slug string) (*Site, error) { if tx == nil { tx = DB() } var site Site err := tx.Where("account_id = ? AND slug = ?", accountID, slug).First(&site).Error if err == nil { return &site, nil } if err != gorm.ErrRecordNotFound { return nil, err } site = Site{AccountID: accountID, Slug: slug, Name: slug, Kind: "production", IsActive: true} if err := tx.Create(&site).Error; err != nil { return nil, err } return &site, nil }