chore: build
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("🔔 <b>Notification</b>\n\n%s", message))
|
||||
go func(id int64, msg string) {
|
||||
if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 <b>Notification</b>\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("🔔 <b>Notification</b>\n\n%s", message))
|
||||
go func(id int64, msg string) {
|
||||
if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 <b>Notification</b>\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("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
|
||||
go func(id int64, m string) {
|
||||
if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\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("🚨 <b>Alerte livreur</b>\n\n%s", capturedBody))
|
||||
go func(id int64, m string) {
|
||||
if err := services.LBTelegram.SendNotification(id, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", m)); err != nil {
|
||||
log.Printf("⚠️ [LB] notification alerte échouée: %v", err)
|
||||
}
|
||||
}(capturedChatID, capturedBody)
|
||||
}
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -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 (?, ?)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
|
||||
}
|
||||
}
|
||||
} else if services.TelegramBot != nil {
|
||||
services.TelegramBot.SendMessage(chatID,
|
||||
"✅ <b>Compte lié avec succès !</b>\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)
|
||||
}
|
||||
|
||||
@@ -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é")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package models
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user