chore: update
ci-api / test (push) Successful in 22m30s
ci-web / test (push) Successful in 13m15s

This commit is contained in:
Nuxgrid
2026-08-01 23:33:11 +02:00
parent cb966fcaf0
commit fb10d25ee2
21 changed files with 612 additions and 81 deletions
+48 -3
View File
@@ -2,12 +2,15 @@
// dans le cluster (une démo = un namespace isolé, TTL 30 jours).
package demos
import "time"
import (
"errors"
"time"
)
// Paramètres capacité / durée de vie.
// Paramètres durée de vie. Pas de quota sur le nombre de démos : seule
// limite réelle, le pool de ressources externes (voir PoolSizePerService).
const (
TTL = 30 * 24 * time.Hour // durée de vie d'une démo
MaxConcurrentDemos = 5 // capacité cible
PoolSizePerService = 5 // slots par service externe poolé
)
@@ -21,6 +24,48 @@ const (
// PooledServices : services nécessitant un slot de pool par démo.
var PooledServices = []string{ServiceTelegram, ServiceNowPayments}
const (
StorageLocal = "local"
StorageS3 = "s3"
)
var (
ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'")
ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3")
)
// ProvisionConfig : réglages saisis par l'admin à la création (popup de
// déploiement), propagés jusqu'au chart backend. Jamais persisté en base —
// notamment les secrets (token Telegram, clés NowPayments), qui ne
// transitent que par le fichier de valeurs Helm temporaire puis le Secret
// k8s du chart, comme les autres secrets.
type ProvisionConfig struct {
TelegramBotToken string
// NowPaymentsAPIKey écrase NOWPAYMENTS_API_KEY. NowPaymentsIPNSecret,
// s'il est fourni, écrase le secret pré-provisionné par le pool
// (ServiceNowPayments) — sinon celui du pool reste utilisé.
NowPaymentsAPIKey string
NowPaymentsIPNSecret string
StorageDriver string // "local" | "s3", "" => "local"
S3Bucket string
S3Endpoint string
}
// Validate vérifie la cohérence du choix de stockage.
func (c ProvisionConfig) Validate() error {
switch c.StorageDriver {
case "", StorageLocal:
return nil
case StorageS3:
if c.S3Bucket == "" || c.S3Endpoint == "" {
return ErrS3ConfigIncomplete
}
return nil
default:
return ErrInvalidStorageDriver
}
}
// Status : état d'une démo.
type Status string
+21 -3
View File
@@ -21,6 +21,14 @@ func NewHandler(svc *Service, helm *HelmProvisioner) *Handler {
type createRequest struct {
LeadID string `json:"lead_id" binding:"omitempty,uuid4"`
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
// Réglages saisis dans le popup de déploiement (voir ProvisionConfig).
TelegramBotToken string `json:"telegram_bot_token" binding:"omitempty"`
NowPaymentsAPIKey string `json:"nowpayments_api_key" binding:"omitempty"`
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret" binding:"omitempty"`
StorageDriver string `json:"storage_driver" binding:"omitempty,oneof=local s3"`
S3Bucket string `json:"s3_bucket" binding:"omitempty,max=63"`
S3Endpoint string `json:"s3_endpoint" binding:"omitempty,max=255"`
}
type DetailDemoUserRequest struct {
@@ -43,13 +51,23 @@ func (h *Handler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
d, err := h.svc.Create(req.LeadID, req.Username)
cfg := ProvisionConfig{
TelegramBotToken: req.TelegramBotToken,
NowPaymentsAPIKey: req.NowPaymentsAPIKey,
NowPaymentsIPNSecret: req.NowPaymentsIPNSecret,
StorageDriver: req.StorageDriver,
S3Bucket: req.S3Bucket,
S3Endpoint: req.S3Endpoint,
}
d, err := h.svc.Create(req.LeadID, req.Username, cfg)
if err != nil {
switch {
case errors.Is(err, ErrCapacityReached):
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
case errors.Is(err, ErrPoolExhausted):
c.JSON(http.StatusConflict, gin.H{"error": "plus de ressources externes disponibles pour le moment (slots Telegram/NowPayments épuisés)"})
case errors.Is(err, ErrUserHasActiveDemo):
c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"})
case errors.Is(err, ErrS3ConfigIncomplete), errors.Is(err, ErrInvalidStorageDriver):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
@@ -17,6 +18,9 @@ import (
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
)
@@ -25,6 +29,7 @@ import (
type HelmProvisioner struct {
cfg *config.Config
k8sClient *kubernetes.Clientset
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
chartsDir string // chemin vers le dossier des charts (ex: /charts)
frontendImage string
@@ -40,6 +45,7 @@ type HelmProvisioner struct {
func NewHelmProvisioner(
cfg *config.Config,
k8sClient *kubernetes.Clientset,
restConfig *rest.Config,
metricsClient *metricsclient.Clientset,
chartsDir string,
helmPath string,
@@ -59,6 +65,7 @@ func NewHelmProvisioner(
return &HelmProvisioner{
cfg: cfg,
k8sClient: k8sClient,
restConfig: restConfig,
metricsClient: metricsClient,
chartsDir: chartsDir,
frontendImage: cfg.FrontendImage,
@@ -84,16 +91,18 @@ const (
// 4. Installe le chart backend
// 5. Installe le chart frontend
// 6. Raccorde la démo au Traefik partagé (WAF + routage HTTPS du sous-domaine)
func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error {
func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error {
// Créer le namespace
if err := h.createNamespace(d.Namespace); err != nil {
return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
}
// Préparer les valeurs
postgresValues := h.buildPostgresValues(d)
redisValues := h.buildRedisValues(d)
backendValues := h.buildBackendValues(d, resources)
// Préparer les valeurs. Pas de persistence à la création : les démos
// d'essai sont éphémères (TTL 30j) — voir UpgradeToPaid pour le passage
// en stockage persistant lorsqu'un client devient payant.
postgresValues := h.buildPostgresValues(d, false)
redisValues := h.buildRedisValues(d, false)
backendValues := h.buildBackendValues(d, resources, cfg)
frontendValues := h.buildFrontendValues(d)
ingressValues := h.buildIngressRouteValues(d)
@@ -162,6 +171,111 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace)
}
// Identifiants postgres des démos — mêmes valeurs codées en dur que
// buildPostgresValues/buildBackendValues (voir ces fonctions).
const (
demoDBUser = "postgres"
demoDBName = "demo_db"
demoDBPass = "demo-postgres-pass"
)
// UpgradeToPaid bascule postgresql/redis d'une démo en stockage persistant
// lorsqu'un client passe en abonnement payant, en préservant les données
// postgres déjà créées pendant l'essai : pg_dump avant la bascule (le volume
// actuel est un emptyDir éphémère), puis restore une fois le nouveau volume
// persistant en place. Redis n'est pas migré (cache/sessions — reconstruit
// naturellement), seule sa persistence est activée pour la suite.
func (h *HelmProvisioner) UpgradeToPaid(d Demo) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
pgPod, err := h.findPod(ctx, d.Namespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql introuvable: %w", err)
}
dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
dump, stderr, err := h.execInPod(ctx, d.Namespace, pgPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil)
if err != nil {
return fmt.Errorf("pg_dump: %w (%s)", err, stderr)
}
// Bascule postgresql en stockage persistant : le pod redémarre (nouveau
// PVC, initialement vide) — c'est pour ça que le dump ci-dessus doit
// être pris AVANT cet appel. helm upgrade --wait attend que le nouveau
// pod soit prêt (readinessProbe = pg_isready) avant de retourner.
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-postgresql", "postgresql", h.buildPostgresValues(d, true)); err != nil {
return fmt.Errorf("upgrade postgresql (persistence): %w", err)
}
newPgPod, err := h.findPod(ctx, d.Namespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql introuvable après upgrade: %w", err)
}
if strings.TrimSpace(dump) != "" {
restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
_, stderr, err := h.execInPod(ctx, d.Namespace, newPgPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump))
if err != nil {
return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr)
}
}
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-redis", "redis", h.buildRedisValues(d, true)); err != nil {
return fmt.Errorf("upgrade redis (persistence): %w", err)
}
log.Printf("Démo %s basculée en stockage persistant (données postgres migrées)", d.Namespace)
return nil
}
// findPod retourne le nom du premier pod du namespace dont le nom contient nameContains.
func (h *HelmProvisioner) findPod(ctx context.Context, namespace, nameContains string) (string, error) {
pods, err := h.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return "", err
}
for _, p := range pods.Items {
if strings.Contains(p.Name, nameContains) {
return p.Name, nil
}
}
return "", fmt.Errorf("aucun pod contenant %q dans %s", nameContains, namespace)
}
// execInPod exécute une commande dans un conteneur et capture stdout/stderr.
// stdin peut être nil (aucune entrée à fournir).
func (h *HelmProvisioner) execInPod(ctx context.Context, namespace, pod, container string, command []string, stdin io.Reader) (string, string, error) {
req := h.k8sClient.CoreV1().RESTClient().Post().
Resource("pods").
Name(pod).
Namespace(namespace).
SubResource("exec").
VersionedParams(&k8sCoreV1.PodExecOptions{
Container: container,
Command: command,
Stdin: stdin != nil,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
executor, err := remotecommand.NewSPDYExecutor(h.restConfig, "POST", req.URL())
if err != nil {
return "", "", fmt.Errorf("exec setup: %w", err)
}
var stdout, stderrBuf bytes.Buffer
err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdin: stdin,
Stdout: &stdout,
Stderr: &stderrBuf,
})
if err != nil {
return stdout.String(), stderrBuf.String(), err
}
return stdout.String(), stderrBuf.String(), nil
}
// createNamespace crée un namespace Kubernetes.
func (h *HelmProvisioner) createNamespace(name string) error {
ns := &k8sCoreV1.Namespace{
@@ -283,10 +397,53 @@ func parseImage(image string) (repo string, tag string) {
}
// buildBackendValues construit les valeurs pour le chart backend.
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} {
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig) map[string]interface{} {
// Parser l'image backend pour séparer repository et tag
backendRepo, backendTag := parseImage(h.backendImage)
storageDriver := cfg.StorageDriver
if storageDriver == "" {
storageDriver = StorageLocal
}
// PVC uploads inutile si le stockage est S3 (rien n'est écrit sur le
// disque local dans ce cas).
uploadsEnabled := storageDriver == StorageLocal
env := map[string]string{
"DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace),
"DB_PORT": "5432",
"DB_PASSWORD": "demo-postgres-pass",
"DB_USER": "postgres",
"DB_NAME": "demo_db",
"DB_SSLMODE": "disable",
"REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace),
"REDIS_PORT": "6379",
"REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis
"API_PORT": "8080",
"NODE_ENV": "production",
"STORAGE_DRIVER": storageDriver,
}
if storageDriver == StorageS3 {
env["S3_BUCKET"] = cfg.S3Bucket
env["S3_ENDPOINT"] = cfg.S3Endpoint
}
secrets := h.buildSecrets(resources)
if cfg.TelegramBotToken != "" {
secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken
}
if cfg.NowPaymentsAPIKey != "" {
secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey
}
if cfg.NowPaymentsIPNSecret != "" {
// Écrase le secret pré-provisionné par le pool (ServiceNowPayments) :
// contrairement au webhook Telegram, l'IPN NowPayments doit
// correspondre à ce qui est réellement configuré sur le compte
// marchand NowPayments, pas à une valeur générée par le pool.
secrets["NOWPAYMENTS_IPN_SECRET"] = cfg.NowPaymentsIPNSecret
}
values := map[string]interface{}{
"replicaCount": 1,
"image": map[string]interface{}{
@@ -303,23 +460,11 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
},
"persistence": map[string]interface{}{
"uploads": map[string]interface{}{
"enabled": false,
"enabled": uploadsEnabled,
},
},
"env": map[string]string{
"DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace),
"DB_PORT": "5432",
"DB_PASSWORD": "demo-postgres-pass",
"DB_USER": "postgres",
"DB_NAME": "demo_db",
"DB_SSLMODE": "disable",
"REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace),
"REDIS_PORT": "6379",
"REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis
"API_PORT": "8080",
"NODE_ENV": "production",
},
"secrets": h.buildSecrets(resources),
"env": env,
"secrets": secrets,
}
return values
@@ -375,7 +520,7 @@ func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]
}
// buildPostgresValues construit les valeurs pour le chart postgresql.
func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} {
func (h *HelmProvisioner) buildPostgresValues(d Demo, persistent bool) map[string]interface{} {
return map[string]interface{}{
"auth": map[string]interface{}{
"password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart)
@@ -387,13 +532,13 @@ func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} {
"port": 5432,
},
"persistence": map[string]interface{}{
"enabled": false,
"enabled": persistent,
},
}
}
// buildRedisValues construit les valeurs pour le chart redis.
func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} {
func (h *HelmProvisioner) buildRedisValues(d Demo, persistent bool) map[string]interface{} {
return map[string]interface{}{
"service": map[string]interface{}{
"type": "ClusterIP",
@@ -403,7 +548,7 @@ func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} {
"password": "demo-redis-pass", // Mot de passe simple pour les démos
},
"persistence": map[string]interface{}{
"enabled": false, // Pas de persistence pour les démos
"enabled": persistent,
},
}
}
@@ -7,9 +7,12 @@ import "context"
// contente d'enfiler les intentions.
type Provisioner interface {
// Provision demande le déploiement d'une démo (asynchrone).
Provision(d Demo, resources []ExternalResource) error
Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error
// Teardown demande la destruction d'une démo.
Teardown(d Demo) error
// UpgradeToPaid bascule postgresql/redis en stockage persistant, en
// migrant les données postgres déjà créées (pg_dump/restore).
UpgradeToPaid(d Demo) error
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
}
@@ -17,8 +20,9 @@ type Provisioner interface {
// Sert au scaffolding et aux tests tant que le worker n'existe pas.
type NoopProvisioner struct{}
func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil }
func (NoopProvisioner) Teardown(Demo) error { return nil }
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
func (NoopProvisioner) Teardown(Demo) error { return nil }
func (NoopProvisioner) UpgradeToPaid(Demo) error { return nil }
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil
}
+49 -20
View File
@@ -11,7 +11,6 @@ import (
)
var (
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
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")
@@ -22,7 +21,6 @@ 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
MaxDemos int // capacité
}
// Service : logique métier des démos (indépendante du transport HTTP).
@@ -38,9 +36,6 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
if cfg.TTL == 0 {
cfg.TTL = TTL
}
if cfg.MaxDemos == 0 {
cfg.MaxDemos = MaxConcurrentDemos
}
if cfg.BaseDomain == "" {
cfg.BaseDomain = "demo.omnex.app"
}
@@ -52,8 +47,12 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
// 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.
func (s *Service) Create(leadID, username string) (Demo, error) {
// 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)
@@ -67,14 +66,6 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
}
}
active, err := s.store.CountActive()
if err != nil {
return Demo{}, err
}
if active >= s.cfg.MaxDemos {
return Demo{}, ErrCapacityReached
}
id := uuid.NewString()
demo := Demo{
ID: id,
@@ -95,12 +86,11 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
}
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix
// Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte.
// 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 {
if errors.Is(err, ErrPoolExhausted) {
return Demo{}, ErrCapacityReached
}
return Demo{}, err
}
@@ -113,7 +103,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
// Lance le déploiement de manière asynchrone (worker inline via goroutine).
go func() {
if err := s.prov.Provision(created, resources); err != nil {
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)
@@ -144,6 +134,45 @@ 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) et déclenche la bascule en stockage persistant (migration
// des données postgres incluse, voir Provisioner.UpgradeToPaid). 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). L'upgrade infra est lent (dump/
// restore/helm) : il tourne en tâche de fond, cette méthode ne bloque pas.
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
updated, err := s.store.Update(*active)
if err != nil {
return err
}
go func() {
if err := s.prov.UpgradeToPaid(updated); err != nil {
log.Printf("Transfert payant échoué pour %s: %v", updated.Namespace, 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)
+7
View File
@@ -18,6 +18,13 @@ func restConfig(cfg *config.Config) (*rest.Config, error) {
return rest.InClusterConfig()
}
// NewRESTConfig expose la config REST brute — nécessaire pour les appels qui
// ne passent pas par le Clientset généré (ex: exec dans un pod via
// remotecommand, utilisé pour la migration pg_dump/restore).
func NewRESTConfig(cfg *config.Config) (*rest.Config, error) {
return restConfig(cfg)
}
func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
k8sCfg, err := restConfig(cfg)
if err != nil {
+15 -2
View File
@@ -2,21 +2,24 @@ package sub
import (
"crypto/rand"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/auth"
"github.com/omnex/control-plane/api/internal/demos"
)
// Handler gère les requêtes HTTP pour les codes de souscription.
type Handler struct {
store Store
demos *demos.Service // optionnel : nil => pas de transfert démo automatique
}
// NewHandler crée un nouveau Handler.
func NewHandler(store Store) *Handler {
return &Handler{store: store}
func NewHandler(store Store, demoSvc *demos.Service) *Handler {
return &Handler{store: store, demos: demoSvc}
}
// createCodeRequest représente la requête pour créer un code.
@@ -122,5 +125,15 @@ func (h *Handler) AddCode(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"})
return
}
// Transfert de la démo active (si elle existe) vers la version payante :
// n'expire plus + bascule stockage persistant en tâche de fond. Ne fait
// jamais échouer la validation du code — le client est déjà premium
// même si sa démo (optionnelle) pose problème.
if h.demos != nil {
if err := h.demos.TransferToPaid(p.Username); err != nil {
log.Printf("Transfert démo vers payant échoué pour %s: %v", p.Username, err)
}
}
c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"})
}