diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 55bcebd5..258df550 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -10,9 +10,9 @@ import ( // AddProductInBasket ajoute un produit au panier de l'utilisateur func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) { - // Rechercher le produit par son nom et catégorie + // Rechercher le produit par son nom et catégorie (case-insensitive) var productID int - productQuery := `SELECT id FROM products WHERE name = $1 AND category = $2` + productQuery := `SELECT id FROM products WHERE LOWER(name) = LOWER($1) AND LOWER(category) = LOWER($2)` err := d.QueryRow(productQuery, nameProduct, category).Scan(&productID) if err == sql.ErrNoRows { return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category) @@ -29,18 +29,19 @@ func (d *Database) AddProductInBasket(username, nameProduct string, quantity flo // Vérifier si le produit existe déjà dans le panier var existingID int - var existingQuantity float64 - checkQuery := `SELECT id, quantity FROM baskets WHERE username = $1 AND product_id = $2` - err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity) + var existingQuantity, existingPrice float64 + checkQuery := `SELECT id, quantity, price FROM baskets WHERE username = $1 AND product_id = $2` + err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity, &existingPrice) if err == nil { - // Produit déjà dans le panier : mettre à jour quantité et prix + // Produit déjà dans le panier : cumuler quantité et prix total de la ligne newQuantity := existingQuantity + quantity + newPrice := existingPrice + price updateQuery := `UPDATE baskets SET quantity = $1, price = $2, created_at = CURRENT_TIMESTAMP WHERE id = $3 RETURNING id, username, product_id, quantity, price, created_at` var basket models.Panier - err = d.QueryRow(updateQuery, newQuantity, price, existingID).Scan( + err = d.QueryRow(updateQuery, newQuantity, newPrice, existingID).Scan( &basket.ID, &basket.Username, &basket.ProductID, @@ -75,7 +76,85 @@ func (d *Database) AddProductInBasket(username, nameProduct string, quantity flo return &basket, nil } -// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée +// GetProductPriceByID récupère le prix d'un produit par son ID et quantité (NUMERIC exact) +func (d *Database) GetProductPriceByID(productID int, quantity float64) (float64, error) { + var price float64 + + // Comparaison NUMERIC précise : évite les problèmes float64 vs NUMERIC(10,3) + exactQuery := ` + SELECT price FROM product_prices + WHERE product_id = $1 AND quantity = ROUND($2::NUMERIC, 3) + LIMIT 1 + ` + err := d.QueryRow(exactQuery, productID, quantity).Scan(&price) + if err == nil { + return price, nil + } + + // Fallback : palier inférieur le plus proche + tierQuery := ` + SELECT price FROM product_prices + WHERE product_id = $1 AND quantity <= ROUND($2::NUMERIC, 3) + ORDER BY quantity DESC LIMIT 1 + ` + err = d.QueryRow(tierQuery, productID, quantity).Scan(&price) + if err != nil { + return 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f: %w", productID, quantity, err) + } + return price, nil +} + +// GetProductStockByID récupère le stock d'un produit par son ID +func (d *Database) GetProductStockByID(productID int) (float64, error) { + var stock float64 + err := d.QueryRow(`SELECT stock FROM products WHERE id = $1`, productID).Scan(&stock) + if err != nil { + return 0, fmt.Errorf("produit %d non trouvé: %w", productID, err) + } + return stock, nil +} + +// AddProductInBasketByID ajoute un produit au panier en utilisant son ID directement +func (d *Database) AddProductInBasketByID(username string, productID int, quantity float64) (*models.Panier, error) { + price, err := d.GetProductPriceByID(productID, quantity) + if err != nil { + return nil, fmt.Errorf("erreur récupération prix: %w", err) + } + + var existingID int + var existingQuantity, existingPrice float64 + checkQuery := `SELECT id, quantity, price FROM baskets WHERE username = $1 AND product_id = $2` + err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity, &existingPrice) + + if err == nil { + newQuantity := existingQuantity + quantity + newPrice := existingPrice + price + updateQuery := `UPDATE baskets SET quantity = $1, price = $2, created_at = CURRENT_TIMESTAMP + WHERE id = $3 RETURNING id, username, product_id, quantity, price, created_at` + var basket models.Panier + err = d.QueryRow(updateQuery, newQuantity, newPrice, existingID).Scan( + &basket.ID, &basket.Username, &basket.ProductID, &basket.Quantity, &basket.Price, &basket.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err) + } + return &basket, nil + } + + insertQuery := `INSERT INTO baskets (username, product_id, quantity, price, created_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) + RETURNING id, username, product_id, quantity, price, created_at` + var basket models.Panier + err = d.QueryRow(insertQuery, username, productID, quantity, price).Scan( + &basket.ID, &basket.Username, &basket.ProductID, &basket.Quantity, &basket.Price, &basket.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err) + } + return &basket, nil +} + +// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy) func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) { var price float64 @@ -100,7 +179,7 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo func (d *Database) GetProductStock(name, category string) (float64, error) { var stock float64 - query := `SELECT stock FROM products WHERE name = $1 AND category = $2` + query := `SELECT stock FROM products WHERE LOWER(name) = LOWER($1) AND LOWER(category) = LOWER($2)` err := d.QueryRow(query, name, category).Scan(&stock) if err != nil { return 0, fmt.Errorf("produit non trouvé: %w", err) @@ -109,7 +188,7 @@ func (d *Database) GetProductStock(name, category string) (float64, error) { } func (d *Database) DecrementProductStock(name, category string, quantity float64) error { - query := `UPDATE products SET stock = stock - $1 WHERE name = $2 AND category = $3 AND stock >= $1` + query := `UPDATE products SET stock = stock - $1 WHERE LOWER(name) = LOWER($2) AND LOWER(category) = LOWER($3) AND stock >= $1` result, err := d.Exec(query, quantity, name, category) if err != nil { return fmt.Errorf("erreur mise à jour stock: %w", err) @@ -223,8 +302,9 @@ func (d *Database) ClearBasket(username string) error { } // GetBasketTotal calcule le montant total du panier d'un utilisateur +// price dans baskets = prix total de la ligne (cumul des ajouts) func (d *Database) GetBasketTotal(username string) (float64, error) { - query := `SELECT COALESCE(SUM(quantity * price), 0) as total + query := `SELECT COALESCE(SUM(price), 0) as total FROM baskets WHERE username = $1` diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index 710e5e6f..032dcefe 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -990,3 +990,22 @@ func (d *Database) CanUserAccessCommand( return exists, err } + +// SaveClientPushToken enregistre le push token Expo d'un client +func (d *Database) SaveClientPushToken(clientID int, pushToken string) error { + _, err := d.Exec(`UPDATE clients SET push_token = $1 WHERE id = $2`, pushToken, clientID) + return err +} + +// DeleteClientPushToken supprime le push token d'un client +func (d *Database) DeleteClientPushToken(clientID int) error { + _, err := d.Exec(`UPDATE clients SET push_token = NULL WHERE id = $1`, clientID) + return err +} + +// GetClientPushToken retourne le push token d'un client par son username +func (d *Database) GetClientPushToken(username string) (string, error) { + var token string + err := d.QueryRow(`SELECT COALESCE(push_token, '') FROM clients WHERE username = $1`, username).Scan(&token) + return token, err +} diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index a2649e35..77c7a80d 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -71,11 +71,16 @@ func InitDB() *Database { log.Println("✅ Tables créées avec succès") - // Migration: ajouter colonne must_change_password si elle n'existe pas - if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT TRUE`); err != nil { + // Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants) + if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil { log.Fatalf("❌ Erreur migration must_change_password: %v", err) } + // Migration: ajouter colonne push_token pour les notifications push + if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS push_token TEXT`); err != nil { + log.Fatalf("❌ Erreur migration push_token: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go index 183d5b6d..20be2e47 100644 --- a/backend/gestion/db/db_notifications.go +++ b/backend/gestion/db/db_notifications.go @@ -1,9 +1,11 @@ package db import ( + "bytes" "encoding/json" "fmt" "log" + "net/http" "time" ) @@ -21,12 +23,57 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa notifJSON, _ := json.Marshal(notification) Redis.LPush(RedisCtx, notifKey, notifJSON) - Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) // Expire après 7 jours + Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) + + // Envoyer push notification si le client a un token enregistré + pushToken, err := d.GetClientPushToken(username) + if err == nil && pushToken != "" { + go sendExpoPush(pushToken, "Uber Stup", message, commandID, notifType) + } log.Printf("📬 Notification envoyée à %s: %s", username, message) return nil } +func sendExpoPush(token, title, body string, commandID int, notifType string) { + payload := map[string]interface{}{ + "to": token, + "title": title, + "body": body, + "channelId": "orders", + "data": map[string]interface{}{ + "command_id": commandID, + "type": notifType, + }, + "sound": "default", + } + + jsonBody, err := json.Marshal(payload) + if err != nil { + log.Printf("❌ [EXPO_PUSH] Erreur marshal: %v", err) + return + } + + req, err := http.NewRequest("POST", "https://exp.host/--/api/v2/push/send", bytes.NewBuffer(jsonBody)) + if err != nil { + log.Printf("❌ [EXPO_PUSH] Erreur création requête: %v", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "gzip, deflate") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("❌ [EXPO_PUSH] Erreur envoi: %v", err) + return + } + defer resp.Body.Close() + + log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (status: %d)", token, resp.StatusCode) +} + // AddDeliveryRating ajoute une note pour un livreur func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error { query := ` diff --git a/backend/gestion/handlers/notifications.go b/backend/gestion/handlers/notifications.go index 922773a4..003034d5 100644 --- a/backend/gestion/handlers/notifications.go +++ b/backend/gestion/handlers/notifications.go @@ -9,6 +9,54 @@ import ( "github.com/gin-gonic/gin" ) +// RegisterPushToken enregistre le push token Expo d'un client +// POST /api/v1/push-token +func RegisterPushToken(c *gin.Context) { + clientID := c.GetInt("client_id") + if clientID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + var req struct { + PushToken string `json:"push_token" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"}) + return + } + + database := c.MustGet("database").(*db.Database) + if err := database.SaveClientPushToken(clientID, req.PushToken); err != nil { + log.Printf("❌ [PUSH_TOKEN] Erreur sauvegarde token pour client %d: %v", clientID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"}) + return + } + + log.Printf("✅ [PUSH_TOKEN] Token enregistré pour client %d", clientID) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// UnregisterPushToken supprime le push token d'un client (au logout) +// DELETE /api/v1/push-token +func UnregisterPushToken(c *gin.Context) { + clientID := c.GetInt("client_id") + if clientID == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + database := c.MustGet("database").(*db.Database) + if err := database.DeleteClientPushToken(clientID); err != nil { + log.Printf("❌ [PUSH_TOKEN] Erreur suppression token pour client %d: %v", clientID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"}) + return + } + + log.Printf("✅ [PUSH_TOKEN] Token supprimé pour client %d", clientID) + c.JSON(http.StatusOK, gin.H{"success": true}) +} + // GetClientNotifications retourne les notifications du client connecté // GET /api/v1/notifications func GetClientNotifications(c *gin.Context) { diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index 9e8aab37..2a93f01a 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -16,6 +16,7 @@ import ( type BasketsRequest struct { Username string `json:"username"` + ProductID int `json:"product_id"` NameProduct string `json:"name_product"` Category string `json:"category"` Quantity float64 `json:"quantity"` @@ -26,16 +27,14 @@ type BasketsRequest struct { // ============================================ // POST /api/v1/panier/add func AddProductsBasket(c *gin.Context) { - db := c.MustGet("database").(*db.Database) + database := c.MustGet("database").(*db.Database) - // Liaison JSON var req BasketsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Requête invalide", "details": err.Error()}) return } - // Récupérer le username depuis JWT ou contexte username, ok := c.Get("username") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) @@ -43,45 +42,65 @@ func AddProductsBasket(c *gin.Context) { } req.Username = username.(string) - // Validation des champs - if req.NameProduct == "" || req.Category == "" || req.Quantity <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Champs invalides"}) + if req.Quantity <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"}) return } - // Vérifier le stock - stock, err := db.GetProductStock(req.NameProduct, req.Category) + // Si product_id fourni par le mobile, on l'utilise directement (plus fiable) + if req.ProductID > 0 { + stock, err := database.GetProductStockByID(req.ProductID) + if err != nil { + log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err) + c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) + return + } + if stock < req.Quantity { + c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) + return + } + panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity) + if err != nil { + log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()}) + return + } + if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil { + log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"}) + return + } + c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) + return + } + + // Fallback : recherche par nom+catégorie (compatibilité) + if req.NameProduct == "" || req.Category == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "product_id ou name_product+category requis"}) + return + } + + stock, err := database.GetProductStock(req.NameProduct, req.Category) if err != nil { + log.Printf("❌ [ADD_PANIER] Produit '%s'/'%s' non trouvé: %v", req.NameProduct, req.Category, err) c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) return } - if stock < req.Quantity { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Stock insuffisant", - "available": stock, - }) + c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) return } - - // Ajouter au panier (ou mettre à jour si déjà présent) - panier, err := db.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category) + panier, err := database.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier"}) + log.Printf("❌ [ADD_PANIER] Erreur ajout '%s': %v", req.NameProduct, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()}) return } - - // Décrémenter le stock - if err := db.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil { + if err := database.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"}) return } - - c.JSON(http.StatusCreated, gin.H{ - "success": true, - "message": "Produit ajouté au panier avec succès", - "panier": panier, - }) + c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) } // ============================================ @@ -144,7 +163,7 @@ func GetAllBaskets(c *gin.Context) { var totalAmount float64 for _, item := range baskets { - totalAmount += item.Price * item.Quantity + totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) } log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) diff --git a/backend/gestion/models/client.go b/backend/gestion/models/client.go index 48f60d30..5c65b991 100644 --- a/backend/gestion/models/client.go +++ b/backend/gestion/models/client.go @@ -17,5 +17,6 @@ type Client struct { CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU LastPenaltyReason string `json:"last_penalty_reason"` MustChangePassword bool `json:"must_change_password"` + PushToken string `json:"-"` CreatedAt time.Time `json:"created_at"` } diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 4d596348..d63e0abc 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -95,6 +95,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cartGroupV1.GET("/notifications", handlers.GetClientNotifications) cartGroupV1.POST("/notifications/read", handlers.MarkNotificationsRead) + // 📱 PUSH TOKEN + cartGroupV1.POST("/push-token", handlers.RegisterPushToken) + cartGroupV1.DELETE("/push-token", handlers.UnregisterPushToken) + // 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil } diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index 34a0ed0a..d88a2fa2 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -169,6 +169,7 @@ export const getCart = async (username: string) => { export const addToCart = async (cartItem: { username?: string; + product_id?: number; name_product: string; category: string; quantity: number; diff --git a/mobile/src/context/CartContext.tsx b/mobile/src/context/CartContext.tsx index c9ec6f41..6591b697 100644 --- a/mobile/src/context/CartContext.tsx +++ b/mobile/src/context/CartContext.tsx @@ -122,6 +122,7 @@ export function CartProvider({ children }: { children: ReactNode }) { try { const response = await apiAddToCart({ username, + product_id: item.product_id, name_product: cleanName, category: (item.category || "autre").toLowerCase().trim(), quantity: Number(item.quantity),