package demos import ( "errors" "log" "time" "github.com/google/uuid" "github.com/omnex/control-plane/api/internal/auth" ) var ( ErrNotFound = errors.New("démo introuvable") ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active") ) // Config du service (injectable pour les tests). type Config struct { BaseDomain string // ex. "demo.omnex.app" HTTPSPort string // port public HTTPS pour l'URL affichée ("443" = omis, sinon ex. NodePort) TTL time.Duration // durée de vie } // Service : logique métier des démos (indépendante du transport HTTP). type Service struct { store Store pool Pool prov Provisioner cfg Config now func() time.Time // horloge injectable } func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service { if cfg.TTL == 0 { cfg.TTL = TTL } if cfg.BaseDomain == "" { cfg.BaseDomain = "demo.omnex.app" } if cfg.HTTPSPort == "" { cfg.HTTPSPort = "443" } return &Service{store: store, pool: pool, prov: prov, cfg: cfg, now: time.Now} } // Create réserve la capacité + le pool, persiste la démo et déclenche le worker. // username (optionnel) rattache la démo à un client existant. Un client n'a // droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage) // n'est jamais persisté : transmis tel quel au provisioning. func (s *Service) Create(leadID, username string, cfg ProvisionConfig) (Demo, error) { if err := cfg.Validate(); err != nil { return Demo{}, err } normalizedUsername := auth.NormalizeUsername(username) if normalizedUsername != "" { existing, err := s.store.ListByUsername(normalizedUsername) if err != nil { return Demo{}, err } for _, d := range existing { if d.Status.Active() { return Demo{}, ErrUserHasActiveDemo } } } id := uuid.NewString() demo := Demo{ ID: id, Username: normalizedUsername, LeadID: leadID, Status: StatusPending, Namespace: "demo-" + shortID(id), CreatedAt: s.now().UTC(), ExpiresAt: s.now().UTC().Add(s.cfg.TTL), } // Port omis dans l'URL s'il s'agit du 443 standard ; sinon affiché // explicitement (ex. NodePort HTTPS sans LoadBalancer). Ne concerne que // l'URL affichée : le Host() de l'IngressRoute reste sans port (voir // buildIngressRouteValues), le port n'y a pas sa place pour le matching. portSuffix := "" if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" { portSuffix = ":" + s.cfg.HTTPSPort } demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix // Réserve les ressources externes (slots Telegram/NowPayments pré- // provisionnés) — contrainte réelle indépendante du nombre de démos, // non concernée par la suppression du quota. resources, err := s.pool.Borrow(id) if err != nil { return Demo{}, err } demo.Status = StatusProvisioning created, err := s.store.Create(demo) if err != nil { _ = s.pool.Return(id) // fail-secure : on rend ce qu'on a emprunté return Demo{}, err } // Lance le déploiement de manière asynchrone (worker inline via goroutine). go func() { if err := s.prov.Provision(created, resources, cfg); err != nil { log.Printf("Provisioning échoué pour %s: %v", created.ID, err) created.Status = StatusFailed _, _ = s.store.Update(created) _ = s.pool.Return(id) return } created.Status = StatusReady _, _ = s.store.Update(created) }() return created, nil } func (s *Service) Get(id string) (Demo, error) { d, ok := s.store.Get(id) if !ok { return Demo{}, ErrNotFound } return d, nil } func (s *Service) List() ([]Demo, error) { return s.store.List() } // ListForUser retourne uniquement les démos rattachées à ce client. func (s *Service) ListForUser(username string) ([]Demo, error) { return s.store.ListByUsername(auth.NormalizeUsername(username)) } // TransferToPaid marque la démo active du client comme abonnement payant // (n'expire plus). Le stockage (PVC postgres/redis) est déjà en place depuis // la création de la démo (voir HelmProvisioner.Provision) : rien à basculer // côté infra, seul le statut change. No-op si le client n'a pas de démo // active — ce n'est pas une erreur (ex: compte créé sans jamais avoir // demandé de démo). func (s *Service) TransferToPaid(username string) error { existing, err := s.store.ListByUsername(auth.NormalizeUsername(username)) if err != nil { return err } var active *Demo for i := range existing { if existing[i].Status.Active() { active = &existing[i] break } } if active == nil { return nil } active.TypeAbo = "premium" active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique if _, err := s.store.Update(*active); err != nil { return err } return nil } // Delete déclenche le teardown et libère le pool. func (s *Service) Delete(id string) (Demo, error) { d, ok := s.store.Get(id) if !ok { return Demo{}, ErrNotFound } if d.Status == StatusExpired { return d, nil // déjà détruite (idempotent) } d.Status = StatusExpiring if _, err := s.store.Update(d); err != nil { return Demo{}, err } if err := s.prov.Teardown(d); err != nil { return Demo{}, err } if err := s.pool.Return(id); err != nil { return Demo{}, err } d.Status = StatusExpired return s.store.Update(d) } // Extend prolonge la démo de TTL à partir de son échéance courante. func (s *Service) Extend(id string) (Demo, error) { d, ok := s.store.Get(id) if !ok { return Demo{}, ErrNotFound } if !d.Status.Extendable() { return Demo{}, ErrNotExtendable } base := d.ExpiresAt if now := s.now().UTC(); base.Before(now) { base = now } d.ExpiresAt = base.Add(s.cfg.TTL) return s.store.Update(d) } // shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court. func shortID(id string) string { clean := "" for _, c := range id { if c != '-' { clean += string(c) } if len(clean) == 8 { break } } return clean }