chore: build

This commit is contained in:
2026-06-09 19:57:19 +02:00
parent 1bee48d81a
commit 72c8003acf
15 changed files with 577 additions and 30 deletions
+81
View File
@@ -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
}
+28
View File
@@ -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
}
+21
View File
@@ -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 -8
View File
@@ -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++
+19 -1
View File
@@ -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 (?, ?)
+182
View File
@@ -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})
}
+1
View File
@@ -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,
})
}
+89 -4
View File
@@ -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)
}
+7 -4
View File
@@ -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é")
+1
View File
@@ -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"`
+6
View File
@@ -0,0 +1,6 @@
package models
type Contact struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
}
+18
View File
@@ -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
}
+13 -7
View File
@@ -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 (
+87
View File
@@ -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
}