diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index 1b7d63ad..4b07856d 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -375,6 +375,16 @@ func ClaimMyReward(c *gin.Context) { } } + // Sans produit éligible pour ce pool (ex: catégories de la récompense mal + // alignées avec celles du pool), on refuse avant de consommer un point — + // sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit + // jamais ajouté au panier (récompense perdue silencieusement). + if len(itemsToAdd) == 0 { + log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey) + c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) + return + } + remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd) if err != nil { if strings.Contains(err.Error(), "pas de récompense disponible") { diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index 88ea1514..e31c0307 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -224,6 +224,51 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) } } +// Si les catégories configurées sur la récompense ne correspondent à aucune +// catégorie du pool réclamé (erreur de configuration admin : pool assigné à +// "test", récompense configurée sur "other"), la liste de produits éligibles +// est vide et la réclamation doit échouer avant de consommer un point — +// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit +// jamais ajouté au panier (régression couverte : la récompense était +// auparavant "consommée" silencieusement sans rien livrer). +func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_no_eligible") + newTestProduct(t, "RewardHTTPNoEligible", 5) + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "other", Type: "free_product", AllProducts: true, Quantity: 1}, + }, + }) + setClientPoolPoints(t, username, "pool_0", 25) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusConflict { + t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String()) + } + + points, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 0 { + t.Errorf("la récompense ne doit PAS être consommée si aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"]) + } + if points["pool_0"] != 25 { + t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"]) + } + + rows := basketRewardItems(t, username) + if len(rows) != 0 { + t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows) + } +} + // Une même catégorie peut avoir les deux types de récompense actifs en // parallèle (un lot de produits offerts + un lot de produits à -50%), chacun // avec sa propre sélection de produits et sa propre quantité. Un seul claim diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 7766c536..83e06c63 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -1440,6 +1440,60 @@ export const cancelCommand = async ( } }; +/** + * ✅ UPDATE OWN COMMAND ADDRESS - Corriger l'adresse de sa propre commande + * PUT /api/v1/commands/:id/address + */ +export const updateOwnCommandAddress = async ( + commandId: number, + deliveryAddress: string, +): Promise<{ success: boolean; message: string }> => { + const token = sessionStorage.getItem("token"); + + if (!token) { + return { + success: false, + message: "Session invalide", + }; + } + + try { + const response = await fetch( + `${API_URL}/commands/${commandId}/address`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ delivery_address: deliveryAddress }), + }, + ); + + const data = await safeJson(response); + + if (!response.ok) { + return { + success: false, + message: data.error || "Erreur lors de la mise à jour de l'adresse", + }; + } + + return { + success: true, + message: data.message || "Adresse mise à jour", + }; + } catch (error) { + return { + success: false, + message: + error instanceof Error + ? error.message + : "Erreur lors de la mise à jour de l'adresse", + }; + } +}; + /** * ✅ GET MY CANCELLATION HISTORY - Historique des annulations * GET /api/v1/my-cancellation-history diff --git a/frontend-prep/src/pages/User/SuiviLivraison.tsx b/frontend-prep/src/pages/User/SuiviLivraison.tsx index 8c46ba28..146ef372 100644 --- a/frontend-prep/src/pages/User/SuiviLivraison.tsx +++ b/frontend-prep/src/pages/User/SuiviLivraison.tsx @@ -14,6 +14,7 @@ import { getOrderETA, confirmReception, cancelCommand, + updateOwnCommandAddress, isUserAuthenticated, getPublicSettings, } from "../../api/api"; @@ -287,6 +288,12 @@ function SuiviLivraison() { useState(null); const [poolNames, setPoolNames] = useState([]); + const [editingAddressOrder, setEditingAddressOrder] = useState< + number | null + >(null); + const [newAddress, setNewAddress] = useState(""); + const [editAddressLoading, setEditAddressLoading] = useState(false); + useEffect(() => { getPublicSettings().then((s) => setPoolNames(s.pool_names ?? [])); }, []); @@ -574,6 +581,53 @@ function SuiviLivraison() { handleCancelOrder(true); }; + const openEditAddressDialog = (orderId: number) => { + if (!isUserAuthenticated()) { + navigate("/login/client", { replace: true }); + return; + } + const order = orders.find((o) => o.id === orderId); + setNewAddress(order ? getDeliveryAddress(order) : ""); + setEditingAddressOrder(orderId); + }; + + const closeEditAddressDialog = () => { + setEditingAddressOrder(null); + setNewAddress(""); + }; + + const handleUpdateAddress = async () => { + if (!editingAddressOrder || !newAddress.trim()) return; + + try { + setEditAddressLoading(true); + const response = await updateOwnCommandAddress( + editingAddressOrder, + newAddress.trim(), + ); + + if (response.success) { + showToast("Adresse mise à jour", "success"); + closeEditAddressDialog(); + loadOrders(); + } else { + showToast( + response.message || "Erreur lors de la mise à jour", + "error", + ); + } + } catch (error: unknown) { + showToast( + error instanceof Error + ? error.message + : "Erreur lors de la mise à jour de l'adresse", + "error", + ); + } finally { + setEditAddressLoading(false); + } + }; + if (loading && orders.length === 0) { return ( <> @@ -1143,6 +1197,27 @@ function SuiviLivraison() { ) : (
+ {(statusLow === + "pending" || + statusLow === + "assigned") && ( + + )}
)} + {/* Dialog de modification d'adresse */} + {editingAddressOrder !== null && ( +
+
e.stopPropagation()} + > +
+

+ {" "} + Modifier l'adresse de livraison +

+
+
+
+ +