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
+}