diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index 6ca18c4d..5474166f 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -650,3 +650,84 @@ func (d *Database) CanUserAccessCommand( return exists, err } + +// GetClientPointsAndRewards retourne les points cumulés et les récompenses réclamées pour un client. +func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[string]int, pointsRedeemed map[string]int, err error) { + var row struct { + PointsExtraJSON []byte `gorm:"column:points_extra"` + PointsRedeemedJSON []byte `gorm:"column:points_redeemed"` + } + if err = d.GDB.Raw(` + SELECT COALESCE(points_extra, '{}'::jsonb) as points_extra, + COALESCE(points_redeemed, '{}'::jsonb) as points_redeemed + FROM clients WHERE username = ?`, username).Scan(&row).Error; err != nil { + return nil, nil, fmt.Errorf("erreur lecture points client: %w", err) + } + pointsExtra = map[string]int{} + pointsRedeemed = map[string]int{} + if len(row.PointsExtraJSON) > 0 { + json.Unmarshal(row.PointsExtraJSON, &pointsExtra) + } + if len(row.PointsRedeemedJSON) > 0 { + json.Unmarshal(row.PointsRedeemedJSON, &pointsRedeemed) + } + return pointsExtra, pointsRedeemed, nil +} + +// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points. +// Retourne le nombre de récompenses disponibles restantes après la réclamation. +func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) { + var points, redeemed int + + err = d.GDB.Transaction(func(tx *gorm.DB) error { + var row struct { + Points int `gorm:"column:pts"` + Redeemed int `gorm:"column:redeemed"` + } + if err := tx.Raw(` + SELECT + COALESCE((points_extra->>?)::int, 0) as pts, + COALESCE((points_redeemed->>?)::int, 0) as redeemed + FROM clients WHERE username = ? FOR UPDATE`, + poolKey, poolKey, username).Scan(&row).Error; err != nil { + return fmt.Errorf("erreur lecture: %w", err) + } + points = row.Points + redeemed = row.Redeemed + + earned := points / threshold + available := earned - redeemed + if available <= 0 { + return fmt.Errorf("pas de récompense disponible pour ce pool") + } + + return tx.Exec(` + UPDATE clients + SET points_redeemed = jsonb_set( + COALESCE(points_redeemed, '{}'::jsonb), + ARRAY[?], + to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1) + ), updated_at = CURRENT_TIMESTAMP + WHERE username = ?`, + poolKey, poolKey, username).Error + }) + if err != nil { + return 0, err + } + + earned := points / threshold + remainingAvailable = earned - (redeemed + 1) + return remainingAvailable, nil +} + +// ResetClientRedeemed remet à zéro les récompenses réclamées (admin). +func (d *Database) ResetClientRedeemed(username, poolKey string) error { + if poolKey != "" { + return d.GDB.Exec(` + UPDATE clients SET points_redeemed = points_redeemed - ?, updated_at = CURRENT_TIMESTAMP + WHERE username = ?`, poolKey, username).Error + } + return d.GDB.Exec(` + UPDATE clients SET points_redeemed = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP + WHERE username = ?`, username).Error +} diff --git a/backend/gestion/db/db_contact.go b/backend/gestion/db/db_contact.go new file mode 100644 index 00000000..1517885c --- /dev/null +++ b/backend/gestion/db/db_contact.go @@ -0,0 +1,28 @@ +package db + +import ( + "gestion/models" +) + +func (d *Database) AddContact(contact *models.Contact) error { + err := d.GDB.Create(contact).Error + return err +} + +func (d *Database) GetContact(id uint) (*models.Contact, error) { + var contact models.Contact + if err := d.GDB.First(&contact, id).Error; err != nil { + return nil, err + } + return &contact, nil +} + +func (d *Database) UpdateContact(contact *models.Contact) error { + err := d.GDB.Save(contact).Error + return err +} + +func (d *Database) DeleteContact(id uint) error { + err := d.GDB.Delete(&models.Contact{}, id).Error + return err +} diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 09d14388..ba7661fe 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -236,11 +236,24 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration clients.points_extra: %v", err) } + // Migration: récompenses réclamées par pool (nb de fois que la récompense a été obtenue) + if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_redeemed JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil { + log.Fatalf("❌ Erreur migration clients.points_redeemed: %v", err) + } + // Migration: flag "à venir" sur les produits if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil { log.Fatalf("❌ Erreur migration products.coming_soon: %v", err) } + // Migration: table contacts (SAV Telegram) + if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS contacts ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL + )`); err != nil { + log.Fatalf("❌ Erreur migration contacts: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() @@ -469,6 +482,14 @@ func (db *Database) createTables() error { `CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`, `CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`, `CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`, + + // ============================ + // TABLE contacts + // ============================ + `CREATE TABLE IF NOT EXISTS contacts ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL + );`, } for _, query := range queries { diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go index 45dc0b55..86292b80 100644 --- a/backend/gestion/db/db_notifications.go +++ b/backend/gestion/db/db_notifications.go @@ -24,9 +24,13 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) - if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok { - go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 Notification\n\n%s", message)) + go func(id int64, msg string) { + if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 Notification\n\n%s", msg)); err != nil { + log.Printf("⚠️ [LB] notification client échouée pour %s: %v", username, err) + } + }(chatID, message) } } @@ -50,9 +54,13 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) - if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok { - go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 Notification\n\n%s", message)) + go func(id int64, msg string) { + if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 Notification\n\n%s", msg)); err != nil { + log.Printf("⚠️ [LB] notification livreur échouée pour %s: %v", username, err) + } + }(chatID, message) } } @@ -87,11 +95,15 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) - if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok { capturedChatID := chatID capturedMsg := msg - go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 Nouvelle commande\n\n%s", capturedMsg)) + go func(id int64, m string) { + if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 Nouvelle commande\n\n%s", m)); err != nil { + log.Printf("⚠️ [LB] notification admin/cabine échouée: %v", err) + } + }(capturedChatID, capturedMsg) } } count++ @@ -126,11 +138,15 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert Redis.LPush(RedisCtx, notifKey, notifJSON) Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) - if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok { capturedChatID := chatID capturedBody := body - go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🚨 Alerte livreur\n\n%s", capturedBody)) + go func(id int64, m string) { + if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🚨 Alerte livreur\n\n%s", m)); err != nil { + log.Printf("⚠️ [LB] notification alerte échouée: %v", err) + } + }(capturedChatID, capturedBody) } } count++ diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 54bbb3db..996bf74e 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -66,7 +66,8 @@ func DefaultSettings() models.AppSettings { }, }, }, - ShopName: "Milieu-Nantais", + ShopName: "Milieu-Nantais", + ContactTelegram: "MLN44LA", DeliveryMode: models.DeliveryModeConfig{ Mode: "single", CategoryRoutes: []models.CategoryRoute{}, @@ -111,6 +112,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) { if err := json.Unmarshal([]byte(row.Value), &pools); err == nil { settings.PointsPools = pools } + case "points_reward": + var reward models.PointsReward + if err := json.Unmarshal([]byte(row.Value), &reward); err == nil { + settings.PointsReward = &reward + } case "referral_enabled": settings.ReferralEnabled = row.Value == "true" case "referral_amount": @@ -140,6 +146,8 @@ func (d *Database) GetSettings() (models.AppSettings, error) { if err := json.Unmarshal([]byte(row.Value), &zones); err == nil { settings.PostalZones = zones } + case "contact_telegram": + settings.ContactTelegram = row.Value case "telegram_bot_token": settings.TelegramBotToken = row.Value case "telegram_bot_username": @@ -186,6 +194,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return fmt.Errorf("erreur sérialisation pools: %w", err) } + rewardJSON, err := json.Marshal(s.PointsReward) + if err != nil { + return fmt.Errorf("erreur sérialisation points_reward: %w", err) + } + if s.NowPaymentsCurrencies == nil { s.NowPaymentsCurrencies = []string{} } @@ -215,11 +228,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return fmt.Errorf("erreur sérialisation delivery_mode: %w", err) } + if s.ContactTelegram == "" { + s.ContactTelegram = "MLN44LA" + } pairs := [][2]string{ {"penalties_enabled", boolStr(s.PenaltiesEnabled)}, {"show_amende_score", boolStr(s.ShowAmendeScore)}, {"points_enabled", boolStr(s.PointsEnabled)}, {"points_pools", string(poolsJSON)}, + {"points_reward", string(rewardJSON)}, {"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, @@ -235,6 +252,7 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { {"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)}, {"delivery_mode", string(deliveryModeJSON)}, {"shop_name", s.ShopName}, + {"contact_telegram", s.ContactTelegram}, } upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go new file mode 100644 index 00000000..f9d3aa0d --- /dev/null +++ b/backend/gestion/handlers/points.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "gestion/db" + "gestion/models" + "gestion/utils" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté. +// La récompense est globale : son seuil s'applique indépendamment à chaque pool. +func GetMyPointsRewards(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + database := c.MustGet("database").(*db.Database) + + settings, err := database.GetSettings() + if err != nil { + utils.ServerErr(c, "Erreur lecture paramètres", err) + return + } + + if !settings.PointsEnabled || len(settings.PointsPools) == 0 { + c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil}) + return + } + + pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username) + if err != nil { + utils.ServerErr(c, "Erreur lecture points", err) + return + } + + reward := settings.PointsReward + + type PoolInfo struct { + Key string `json:"key"` + Name string `json:"name"` + Points int `json:"points"` + RewardsEarned int `json:"rewards_earned"` + RewardsClaimed int `json:"rewards_claimed"` + RewardsAvailable int `json:"rewards_available"` + EligibleConfigs []models.RewardCategoryConfig `json:"eligible_configs"` + } + + pools := make([]PoolInfo, 0, len(settings.PointsPools)) + for _, pool := range settings.PointsPools { + pts := pointsExtra[pool.Key] + redeemed := pointsRedeemed[pool.Key] + + var earned, available int + if reward != nil && reward.Threshold > 0 { + earned = pts / reward.Threshold + available = earned - redeemed + if available < 0 { + available = 0 + } + } + + // Filtrer les category_configs aux seules catégories du pool + poolCats := make(map[string]bool, len(pool.Categories)) + for _, c := range pool.Categories { + poolCats[c] = true + } + eligibleConfigs := make([]models.RewardCategoryConfig, 0) + if reward != nil { + for _, cfg := range reward.CategoryConfigs { + if poolCats[cfg.Category] { + eligibleConfigs = append(eligibleConfigs, cfg) + } + } + } + + pools = append(pools, PoolInfo{ + Key: pool.Key, + Name: pool.Name, + Points: pts, + RewardsEarned: earned, + RewardsClaimed: redeemed, + RewardsAvailable: available, + EligibleConfigs: eligibleConfigs, + }) + } + + // Retourner la récompense sans category_configs (les configs sont par pool) + var rewardMeta gin.H + if reward != nil { + rewardMeta = gin.H{ + "threshold": reward.Threshold, + "type": reward.Type, + "description": reward.Description, + } + } + + c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta}) +} + +// ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil. +func ClaimMyReward(c *gin.Context) { + username := c.GetString("username") + if username == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + var req struct { + PoolKey string `json:"pool_key" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"}) + return + } + + database := c.MustGet("database").(*db.Database) + + settings, err := database.GetSettings() + if err != nil { + utils.ServerErr(c, "Erreur lecture paramètres", err) + return + } + + if !settings.PointsEnabled { + c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"}) + return + } + + reward := settings.PointsReward + if reward == nil || reward.Threshold <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"}) + return + } + + // Vérifier que le pool existe + poolExists := false + for _, p := range settings.PointsPools { + if p.Key == req.PoolKey { + poolExists = true + break + } + } + if !poolExists { + c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"}) + return + } + + remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold) + if err != nil { + if strings.Contains(err.Error(), "pas de récompense disponible") { + c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"}) + return + } + utils.ServerErr(c, "Erreur réclamation récompense", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "description": reward.Description, + "remaining_rewards": remaining, + }) +} + +// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin). +func AdminResetClientRedeemed(c *gin.Context) { + username := c.Param("username") + poolKey := c.Query("pool_key") + + database := c.MustGet("database").(*db.Database) + if err := database.ResetClientRedeemed(username, poolKey); err != nil { + utils.ServerErr(c, "Erreur reset récompenses", err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/backend/gestion/handlers/settings.go b/backend/gestion/handlers/settings.go index 01268f9f..3482d2cb 100644 --- a/backend/gestion/handlers/settings.go +++ b/backend/gestion/handlers/settings.go @@ -47,6 +47,7 @@ func GetPublicSettings(c *gin.Context) { "telegram_notifications_enabled": settings.TelegramNotificationsEnabled, "shop_name": settings.ShopName, "two_fa_enabled": settings.Telegram2FAEnabled, + "contact_telegram": settings.ContactTelegram, }) } diff --git a/backend/gestion/handlers/telegram.go b/backend/gestion/handlers/telegram.go index 5a1062d9..f4fa29a6 100644 --- a/backend/gestion/handlers/telegram.go +++ b/backend/gestion/handlers/telegram.go @@ -6,6 +6,7 @@ import ( "gestion/services" "log" "net/http" + "os" "strings" "github.com/gin-gonic/gin" @@ -91,7 +92,17 @@ func handleLinkAccount(c *gin.Context, token string, chatID int64) { } log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID) - if services.TelegramBot != nil { + + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() { + if err := services.LBTelegram.EnrollUser(chatID, username, role); err != nil { + log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err) + // fallback: confirmation directe via le bot principal + if services.TelegramBot != nil { + services.TelegramBot.SendMessage(chatID, + "✅ Compte lié avec succès !\n\nVous recevrez désormais vos notifications ici.") + } + } + } else if services.TelegramBot != nil { services.TelegramBot.SendMessage(chatID, "✅ Compte lié avec succès !\n\nVous recevrez désormais vos notifications ici.") } @@ -118,9 +129,14 @@ func GenerateClientLinkToken(c *gin.Context) { return } + botUsername := services.TelegramBot.BotUsername + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.LBTelegram.Bot1Username != "" { + botUsername = services.LBTelegram.Bot1Username + } + c.JSON(http.StatusOK, gin.H{ "token": token, - "link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, + "link_url": "https://t.me/" + botUsername + "?start=" + token, "message": "/start " + token, "expires_in": 600, }) @@ -145,9 +161,14 @@ func GenerateLivreurLinkToken(c *gin.Context) { return } + botUsername := services.TelegramBot.BotUsername + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.LBTelegram.Bot1Username != "" { + botUsername = services.LBTelegram.Bot1Username + } + c.JSON(http.StatusOK, gin.H{ "token": token, - "link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, + "link_url": "https://t.me/" + botUsername + "?start=" + token, "message": "/start " + token, "expires_in": 600, }) @@ -180,9 +201,14 @@ func GenerateAdminLinkToken(c *gin.Context) { return } + botUsername := services.TelegramBot.BotUsername + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() && services.LBTelegram.Bot1Username != "" { + botUsername = services.LBTelegram.Bot1Username + } + c.JSON(http.StatusOK, gin.H{ "token": token, - "link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, + "link_url": "https://t.me/" + botUsername + "?start=" + token, "message": "/start " + token, "expires_in": 600, }) @@ -297,3 +323,62 @@ func UnlinkAdminTelegram(c *gin.Context) { log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username) c.JSON(http.StatusOK, gin.H{"success": true}) } + +// ============================================ +// LIAISON INTERNE (appelée par LBTelegram) +// ============================================ + +// POST /api/internal/telegram/link +// Appelée par LBTelegram quand Bot1 reçoit /start TOKEN. +// Valide le token, enregistre le chat_id, déclenche l'enrollment. +func InternalTelegramLink(c *gin.Context) { + secret := c.GetHeader("X-Internal-Secret") + expected := os.Getenv("BACKEND_LINK_SECRET") + if expected == "" || secret != expected { + c.Status(http.StatusUnauthorized) + return + } + + var req struct { + ChatID int64 `json:"chat_id" binding:"required"` + Token string `json:"token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + database := c.MustGet("database").(*db.Database) + + username, role, err := db.ValidateAndConsumeLinkToken(req.Token) + if err != nil { + log.Printf("⚠️ [TELEGRAM_LINK_INTERNAL] Token invalide: %v", err) + c.Status(http.StatusUnauthorized) + return + } + + var saveErr error + switch role { + case "client": + saveErr = database.SaveClientTelegramChatID(username, req.ChatID) + default: + saveErr = database.SaveUserTelegramChatID(username, req.ChatID) + } + if saveErr != nil { + log.Printf("❌ [TELEGRAM_LINK_INTERNAL] Erreur sauvegarde pour %s: %v", username, saveErr) + c.Status(http.StatusInternalServerError) + return + } + + log.Printf("✅ [TELEGRAM_LINK_INTERNAL] Compte %s (%s) lié via Bot1 (chat_id %d)", username, role, req.ChatID) + + if services.LBTelegram != nil && services.LBTelegram.IsConfigured() { + if err := services.LBTelegram.EnrollUser(req.ChatID, username, role); err != nil { + log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err) + c.Status(http.StatusInternalServerError) + return + } + } + + c.Status(http.StatusOK) +} diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 9bce433a..d6ba9bd4 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -1,7 +1,3 @@ -// ============================================ -// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO -// ============================================ - package main import ( @@ -42,6 +38,13 @@ func main() { geoService := services.NewGeoService(db.Redis, db.RedisCtx) log.Println("✅ Service de géolocalisation initialisé") + lbService := services.NewLBTelegramService() + if lbService.IsConfigured() { + log.Println("✅ Service LBTelegram initialisé") + } else { + log.Println("ℹ️ Service LBTelegram désactivé (LBTELEGRAM_URL non défini)") + } + telegramService := services.NewTelegramService() if telegramService.IsConfigured() { log.Println("✅ Service Telegram initialisé") diff --git a/backend/gestion/models/client.go b/backend/gestion/models/client.go index e2d2f1b1..0c990a14 100644 --- a/backend/gestion/models/client.go +++ b/backend/gestion/models/client.go @@ -18,6 +18,7 @@ type Client struct { MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"` ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"` PointsExtra map[string]int `gorm:"-" json:"points_extra"` + PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"` Parrain string `gorm:"column:parrain" json:"parrain"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/backend/gestion/models/contact.go b/backend/gestion/models/contact.go new file mode 100644 index 00000000..56ee97e9 --- /dev/null +++ b/backend/gestion/models/contact.go @@ -0,0 +1,6 @@ +package models + +type Contact struct { + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` +} diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 48cc3b7a..d0e6b9b0 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -14,6 +14,22 @@ type PointsTier struct { Points int `json:"points"` } +// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense +type RewardCategoryConfig struct { + Category string `json:"category"` // nom de la catégorie + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false + Amount float64 `json:"amount"` // valeur monétaire de la récompense pour cette catégorie (ex: 30.0) +} + +// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés +type PointsReward struct { + Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) + Type string `json:"type"` // "free_product" | "half_price_product" | "custom" + Description string `json:"description"` // description libre affichée au client + CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles +} + // DaySchedule représente les horaires de livraison pour un jour de la semaine type DaySchedule struct { Enabled bool `json:"enabled"` @@ -66,6 +82,7 @@ type AppSettings struct { PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés + PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto @@ -81,4 +98,5 @@ type AppSettings struct { DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs + ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact } diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index bc7f789d..e419cad7 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -1,7 +1,3 @@ -// ============================================ -// routes/routes.go - VERSION CORRIGÉE COMPLÈTE -// ============================================ - package routes import ( @@ -116,6 +112,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo) + // 🏆 POINTS & RÉCOMPENSES CLIENT + cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards) + cartGroupV1.POST("/points/claim", handlers.ClaimMyReward) + // 💸 STATUT PAIEMENT CRYPTO cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus) } @@ -130,6 +130,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ router.POST("/webhook/telegram", handlers.TelegramWebhook) + // ============================================ + // 🔗 LIAISON INTERNE TELEGRAM (appelée par LBTelegram) + // ============================================ + router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink) + // ============================================ // 📋 PATTERN v2: ADMIN API // ============================================ @@ -263,9 +268,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende - adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 - adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool - adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool + adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 + adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool + adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool + adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats) diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go index 8a11aaf5..b879d6a6 100644 --- a/backend/gestion/services/adresses_correction.go +++ b/backend/gestion/services/adresses_correction.go @@ -1,9 +1,3 @@ -// ============================================ -// services/address_correction.go -// Correction automatique des adresses mal orthographiées -// Stratégie : Nominatim fuzzy → suggestions structurées → fallback -// ============================================ - package services import ( diff --git a/backend/gestion/services/lbtelegram.go b/backend/gestion/services/lbtelegram.go new file mode 100644 index 00000000..85195221 --- /dev/null +++ b/backend/gestion/services/lbtelegram.go @@ -0,0 +1,87 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "time" +) + +var LBTelegram *LBTelegramService + +type LBTelegramService struct { + gatewayURL string + Bot1Username string + client *http.Client +} + +func NewLBTelegramService() *LBTelegramService { + url := os.Getenv("LBTELEGRAM_URL") + if url == "" { + url = "http://lbtelegram:8081" + } + svc := &LBTelegramService{ + gatewayURL: url, + Bot1Username: os.Getenv("LBTELEGRAM_BOT1_USERNAME"), + client: &http.Client{Timeout: 10 * time.Second}, + } + LBTelegram = svc + return svc +} + +func (s *LBTelegramService) IsConfigured() bool { + return os.Getenv("LBTELEGRAM_URL") != "" +} + +// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte. +// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3). +func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error { + payload := map[string]interface{}{ + "user_id": chatID, + "username": username, + "role": role, + "chat_id": chatID, + } + + body, _ := json.Marshal(payload) + resp, err := s.client.Post(s.gatewayURL+"/enrollment/begin", "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("enrollment: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("enrollment HTTP %d: %s", resp.StatusCode, string(b)) + } + + log.Printf("✅ [LB] Enrollment OK pour %s (%s)", username, role) + return nil +} + +// SendNotification envoie un message via la gateway LBTelegram. +// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn). +func (s *LBTelegramService) SendNotification(userID int64, message string) error { + payload := map[string]interface{}{ + "user_id": userID, + "message": message, + } + + body, _ := json.Marshal(payload) + resp, err := s.client.Post(s.gatewayURL+"/notify", "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("notify: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("notify HTTP %d: %s", resp.StatusCode, string(b)) + } + + return nil +}