chore: update

This commit is contained in:
2026-03-11 19:57:34 +01:00
parent f6b3948839
commit 375aa38454
62 changed files with 2714 additions and 1981 deletions
+7 -15
View File
@@ -12,7 +12,7 @@ on:
jobs:
lint:
name: Lint (go vet + staticcheck)
name: Static Analysis (golangci-lint)
runs-on: ubuntu-latest
steps:
@@ -24,20 +24,12 @@ jobs:
go-version: "1.24.4"
cache-dependency-path: backend/gestion/go.sum
- name: Download dependencies
working-directory: backend/gestion
run: go mod download
- name: go vet
working-directory: backend/gestion
run: go vet ./...
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: staticcheck
working-directory: backend/gestion
run: staticcheck ./... || true
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: backend/gestion
args: --timeout=5m
build:
name: Build
+3 -8
View File
@@ -341,7 +341,6 @@ sequenceDiagram
Note over C,R: 2. Livraison
L->>API: GET /livreur/deliveries
API-->>L: Liste commandes
L->>API: PUT /deliveries/:id/status {support}
L->>API: POST /location/update {lat, lng}
API->>R: Update position
API->>R: Publish update
@@ -940,13 +939,13 @@ Authorization: Bearer <token>
**Statuts possibles:**
- `pending` - En attente d'assignation
- `assigned` - Assignée à un livreur
- `support` - Prise en charge par le livreur
- `en_route` - Livreur en chemin
- `arrived` - Livreur arrivé
- `livre` - Livrée (en attente d'approbation)
- `approved` - Approuvée par le client
- `cancelled` - Annulée
- `failed` - Échec de livraison
- `disabled` - Désactivée
---
@@ -1570,7 +1569,6 @@ Content-Type: application/json
```
**Statuts valides pour livreur:**
- `support` - Prise en charge
- `assigned` - Assigné
- `en_route` - En route vers le client
- `arrived` - Arrivé à destination
@@ -2253,12 +2251,9 @@ stateDiagram-v2
pending --> assigned: Auto-assignation GPS
pending --> pending: Geocodage en cours
assigned --> support: Livreur prend en charge
assigned --> en_route: Livreur demarre
assigned --> cancelled: Annulation
support --> en_route: Livreur demarre
support --> cancelled: Annulation
en_route --> arrived: Position = Destination
en_route --> en_route: Mise a jour position
+3 -3
View File
@@ -20,15 +20,15 @@ backend_dir: "/home/ubuntu/backend"
backend_binary: "/home/ubuntu/backend/main"
uploads_dir: "/home/ubuntu/backend/uploads"
frontend_port: 5173
domain_name: "mln-uber.club"
domain_name: "uber-demo.club"
# Utilisateurs et permissions
user_web: "www-data" # Utilisateur pour les applications web
user_deploy: "ubuntu" # Utilisateur pour le déploiement
user_owner: "root" # Propriétaire des fichiers systemd
ip: "5.181.0.112"
# Backend
backend_local_port: 8080
backend_port: 443
backend_port: 80
# Timeouts
nginx_proxy_timeout: 60
+4
View File
@@ -1,9 +1,13 @@
[all]
uber-stup ansible_host=5.252.20.129 ansible_user=root ansible_ssh_pass=rL9lY6YkcDQmfRuZ3Z
uber-stup-web ansible_host=185.234.9.102 ansible_user=root ansible_ssh_pass=hTASFYOydY46haeO3J
demo-uber ansible_host=5.181.0.112 ansible_user=root ansible_ssh_pass=jQCpl49FcISQfHaF8j
[uber-stup]
uber-stup ansible_host=5.252.20.129 ansible_user=root ansible_ssh_pass=rL9lY6YkcDQmfRuZ3Z
[uber-stup-web]
uber-stup-web ansible_host=185.234.9.102 ansible_user=root ansible_ssh_pass=hTASFYOydY46haeO3J
[demo-uber]
demo-uber ansible_host=5.181.0.112 ansible_user=root ansible_ssh_pass=jQCpl49FcISQfHaF8j
+50 -53
View File
@@ -1,6 +1,6 @@
---
- name: Installation et configuration du frontend et backend
hosts: uber-stup
hosts: demo-uber
become: true
gather_facts: true
@@ -203,7 +203,6 @@
port: "{{ item }}"
proto: tcp
loop:
- "{{ backend_port }}"
- 80
- 22
@@ -211,15 +210,15 @@
ansible.builtin.ufw:
state: enabled
- name: Vérifier si un certificat existe déjà
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
register: cert_file
tags: [certbot]
# - name: Vérifier si un certificat existe déjà
# ansible.builtin.stat:
# path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
# register: cert_file
# tags: [certbot]
#
- name: Déployer la configuration Nginx HTTP
ansible.builtin.template:
src: templates/nginx.conf.j2
src: templates/nginx2.conf.j2
dest: /etc/nginx/sites-available/api
vars:
ssl_enabled: false
@@ -254,59 +253,57 @@
# ============================================================
# Certificat SSL Let's Encrypt
# ============================================================
- name: Générer le certificat SSL avec Certbot
ansible.builtin.command: >
certbot certonly --nginx
-d {{ domain_name }}
--non-interactive
--agree-tos
--email admin@{{ domain_name }}
when: not cert_file.stat.exists
tags: [certbot]
# - name: Générer le certificat SSL avec Certbot
# ansible.builtin.command: >
# certbot certonly --nginx
# -d {{ domain_name }}
# --non-interactive
# --agree-tos
# --email admin@{{ domain_name }}
# tags: [certbot]
# ============================================================
# Nginx - reconfiguration HTTPS après certificat
# ============================================================
- name: Vérifier la présence du certificat
ansible.builtin.stat:
path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
register: cert_file_after
tags: [nginx]
# - name: Vérifier la présence du certificat
# ansible.builtin.stat:
# path: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"
# register: cert_file_after
# tags: [nginx]
- name: Déployer la configuration Nginx HTTPS
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/api
vars:
ssl_enabled: true
when: cert_file_after.stat.exists
notify: Restart nginx
tags: [nginx]
#- name: Déployer la configuration Nginx HTTPS
# ansible.builtin.template:
# src: templates/nginx.conf.j2
# dest: /etc/nginx/sites-available/api
# vars:
# ssl_enabled: true
# when: cert_file_after.stat.exists
# notify: Restart nginx
# tags: [nginx]
- name: Test de la configuration Nginx finale
ansible.builtin.command: nginx -t
changed_when: false
tags: [nginx]
#- name: Test de la configuration Nginx finale
# ansible.builtin.command: nginx -t
# changed_when: false
# tags: [nginx]
- name: Redémarrage de Nginx avec SSL
ansible.builtin.systemd:
name: nginx
state: restarted
when: cert_file_after.stat.exists
tags: [nginx]
# - name: Redémarrage de Nginx avec SSL
# ansible.builtin.systemd:
# name: nginx
# state: restarted
# when: cert_file_after.stat.exists
# tags: [nginx]
- name: Vérifier le renouvellement automatique
ansible.builtin.command: certbot renew --dry-run
register: certbot_renew
changed_when: false
failed_when: false
tags: [certbot]
# - name: Vérifier le renouvellement automatique
# ansible.builtin.command: certbot renew --dry-run
# register: certbot_renew
# changed_when: false
# failed_when: false
# tags: [certbot]
- name: Afficher le statut du renouvellement
ansible.builtin.debug:
msg: "{{ certbot_renew.stdout_lines }}"
tags: [certbot]
# - name: Afficher le statut du renouvellement
# ansible.builtin.debug:
# msg: "{{ certbot_renew.stdout_lines }}"
# tags: [certbot]
handlers:
- name: Reload systemd
+2 -76
View File
@@ -3,7 +3,7 @@
# PostgreSQL Installation (vm-postgres uniquement)
# ============================================
- name: Installation et configuration de PostgreSQL
hosts: uber-stup
hosts: demo-uber
become: true
gather_facts: true
@@ -120,80 +120,6 @@
privs: ALL
state: present
# ============================================================
# Données initiales - corrections d'adresses
# ============================================================
- name: Insérer les corrections d'adresses
become_user: postgres
ansible.builtin.postgresql_query:
db: "{{ db_name }}"
query: |
INSERT INTO adresse_correction (invalid_address, correct_address) VALUES
('6 Place Alfred Lallié 44000 Nantes', '13 Rue Saint-Rogatien 44000 Nantes'),
('26 boulevard Vincent gâche 44200', '62 Rue des Français Libres 44200'),
('41 quai Malakoff 44000', '41 rue du pré Gauchet 44000'),
('au 6 rue du Rhône 44100', '106 rue du bois Hardy 44100'),
('10 allée de l''île gloriette 44000', '8 rue Deurbroucq 44000'),
('1 Ter Boulevard de Launay 44100', '5 Rue Lavoisier 44100'),
('6 Boulevard de la prairie au duc 44200', '10 Rue Alain Barbe Torte 44200'),
('4 rue de mauves , 44470 thouaré sur Loire', '4 Rue de la Halbarderie 44470 Thouaré-sur-Loire'),
('26 rue Louis blanc, 44200 Nantes', '6 Rue de Hercé 44200 Nantes'),
('4 rue la tour d''Auvergne 44200 nantes', '6 Rue de Hercé 44200 Nantes'),
('12 rue d''Orléans 44000', '10 rue de l''industrie 44000'),
('3 rue Bertrand Geslin 44000 Nantes', '10 Rue de Sévigné 44000 Nantes'),
('33 boulevard de doulon 44300', '3 Avenue du Forgeron 44300 Nantes'),
('2 rue Stanislas Baudry 44000 Nantes', '9 Rue de Richebourg 44000 Nantes'),
('8 Place Saint-Pierre, 44000 Nantes', '2 Rue Saint-Denis 44000 Nantes'),
('20 boulevard du petit port 44300', '3 Rue du Transvaal 44300 Nantes'),
('5 cours Sully 44000 Nantes', '75 Rue Préfet Bonnefoy 44000'),
('50 rue Paul Bellamy 44000', '2 rue du docteur Heurteaux 44000'),
('33 rue de la petite sensive 44300', '8 Rue du Docteur Chenantais 44300'),
('45 rue Louis guiotton 44300 Nantes', '59 Rue de Nancy 44300 Nantes'),
('6 rue Meuris 44000 Nantes', '3 Rue Daubenton 44100 Nantes'),
('26 rue Félix Faure 44000', '12 rue bonaparte 44000'),
('1 rue Charles Dullin 44100 nantes', '12 Avenue de l''Églantine 44800 Saint-Herblain'),
('16 rue Anatole de monzie 44200', '1 Rue Georges Leygues 44200 Nantes'),
('52 boulevard de l''Estuaire 44200', '1 Rue Ruth First 44200 Nantes'),
('186 Route de Saint-Joseph 44300 Nantes', 'Rue de Pontecorvo 44300 Nantes'),
('16 Rue Jules Launey 44100 Nantes', '5 Chemin du Pressoir Chênaie 44100 Nantes'),
('1 rue maréchal Lyautey 44000', '9 rue Louis Mékarski 44000'),
('74 boulevard de la prairie au duc 44200', '7 Rue René Siegfried 44200'),
('1 impasse du baut 44300 Nantes', '8 Avenue d''Assise 44300 Nantes'),
('5 rue moquechien, 44000 Nantes', '4 Rue Adolphe Moitié 44000 Nantes'),
('21 chaussée de la madeleine 44000', '4 bis Rue Perrault 44000 Nantes'),
('31 boulevard Michelet 44000', '26 Rue de la Haute Forêt 44000 Nantes'),
('95 Boulevard longchamp 44300', '7 Petite Avenue de Longchamp 44300'),
('7 Quai Marcel Boissard, 44400 Rezé', '6 Rue Jean Jouneau 44400 Rezé'),
('43 avenue de la libération 44400', '10 rue de l''Erdronnière 44400'),
('165 route de rennes 44700 orvault', '5 Rue de l''Ascension 44700 Orvault'),
('17rue yves bodiguel 44000', '8 Rue Frédureau 44000 Nantes'),
('176 boulevard Jules verne 44300', '44 Rue des Platanes 44300 Nantes'),
('133 Boulevard Jules Verne 44300', '28 Rue Moriceau Thébault 44300'),
('53 Boulevard Jules Verne 44300', '66 Rue de la Moisdonnière 44300'),
('7 chaussé de la madeleine, 44000', '3 Rue Perrault 44000 Nantes'),
('LA CHEVROLIÈRE', '12 Rue des Ajoncs 44400 Rezé'),
('9 rue Delattre de tassigny 44000 nantes', '1 Rue des Cadeniers 44000 Nantes'),
('14 place de l''Oratoire 44000 Nantes', '2 Rue Élie Delaunay 44000 Nantes'),
('19 allée Baco, 44000 Nantes', '23 Rue Crucy 44000 Nantes'),
('21bd babin chevaye 44200', '7 Rue de Hercé 44200 Nantes'),
('49 boulevard dalby 44000', '51 Rue de la Ville en Pierre 44000'),
('96 route de Vannes 44100', '14 Avenue des Coucous 44300'),
('1 rue du Luxembourg 44000 Nantes', '5 Rue de l''Indre 44000 Nantes'),
('3 rue de bretagne 44880 Sautron', '9 Rue de Flore 44880 Sautron'),
('42 rue paul bellamy 44000 nantes', '5 Rue de Bouillé 44000 Nantes'),
('125 Rue Paul Bellamy 44000 Nantes', '14 Rue Anatole le Braz 44000 Nantes'),
('214 Rue Paul Bellamy 44000 Nantes', '17 Rue de la Saulzinière 44000 Nantes'),
('49 Boulevard Robert Schuman 44300 Nantes', '47 Rue Georges Bizot 44300 Nantes'),
('142 Boulevard Robert Schuman 44300', '10 Rue St-Mihiel 44300 Nantes'),
('23 route de la chapelle sur erdre 44300', '14 Rue de la Boulonnerie 44300 Nantes'),
('27 Rue Pierre Blard 44800 Saint-Herblain', '2 Rue Louis Boutin 44800 Saint-Herblain'),
('109 Boulevard Ernest Dalby 44000 Nantes', '53 bis rue de la ville en pierre 44000'),
('13 rue pitre chevalier 44000', '10 Rue Jean-Émile Laboureur 44000'),
('Rue lesage - 44000 - Nantes', '4 rue Maurice Sibille 44000'),
('86 Rue de la Bourgeonnière 44300', '6 Avenue de l''Alverne 44300'),
('8 boulevard Émile romanet 44100', '33 Route de St-Herblain 44100 Nantes')
ON CONFLICT (invalid_address) DO NOTHING;
# ============================================================
# Authentification pg_hba.conf
# ============================================================
@@ -250,7 +176,7 @@
# Redis Installation (vm-redis uniquement)
# ============================================
- name: Installation et configuration de Redis
hosts: uber-stup
hosts: demo-uber
become: true
gather_facts: true
+142
View File
@@ -0,0 +1,142 @@
# =========================================================
# Request ID for correlation
# =========================================================
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
# =========================================================
# Rate limiting
# =========================================================
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=uploads:10m rate=10r/m;
# =========================================================
# Upstream backend
# =========================================================
upstream backend {
server 127.0.0.1:{{ backend_local_port }} max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 100;
keepalive_timeout 60s;
}
# =========================================================
# API Server
# =========================================================
server {
listen {{ backend_port }};
listen [::]:{{ backend_port }};
server_name {{ ip }};
client_max_body_size {{ nginx_max_body_size }};
server_tokens off;
# =========================================================
# Security headers
# =========================================================
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
# =========================================================
# API proxy
# =========================================================
location / {
limit_req zone=api burst=20 nodelay;
limit_except GET POST PUT PATCH DELETE OPTIONS { deny all; }
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $req_id;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# =========================================================
# Uploads
# =========================================================
location /uploads/ {
alias {{ uploads_dir }}/;
limit_req zone=uploads burst=20 nodelay;
limit_except GET HEAD { deny all; }
# Bloquer les fichiers exécutables
location ~* \.(php|php5|phtml|sh|py|pl|cgi|exe|asp|aspx|jsp)$ {
deny all;
}
expires 30d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
location ^~ /uploads/images/ {
alias {{ uploads_dir }}/images/;
limit_req zone=uploads burst=20 nodelay;
limit_except GET HEAD OPTIONS { deny all; }
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header X-Content-Type-Options "nosniff" always;
expires 30d;
add_header Cache-Control "public, immutable" always;
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
image/webp webp;
image/svg+xml svg;
}
default_type image/jpeg;
}
location ^~ /uploads/videos/ {
alias {{ uploads_dir }}/videos/;
limit_req zone=uploads burst=20 nodelay;
limit_except GET HEAD OPTIONS { deny all; }
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header X-Content-Type-Options "nosniff" always;
expires 30d;
add_header Cache-Control "public, immutable" always;
types {
video/mp4 mp4;
video/webm webm;
video/ogg ogv;
}
default_type video/mp4;
}
# =========================================================
# Deny hidden files and sensitive files
# =========================================================
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* (\.env|\.git|package\.json|package-lock\.json|yarn\.lock|Dockerfile|docker-compose\.yml)$ {
deny all;
access_log off;
log_not_found off;
}
}
+33
View File
@@ -0,0 +1,33 @@
version: "2"
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- misspell
- bodyclose
- noctx
linters-settings:
gosec:
excludes:
- G104 # erreurs non vérifiées (couvertes par errcheck)
gocritic:
disabled-checks:
- appendAssign
- sloppyReassign
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
max-issues-per-linter: 50
max-same-issues: 5
+13 -13
View File
@@ -5,15 +5,15 @@ import (
"gestion/models"
)
func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
query := `
INSERT INTO alerte_policy (username, status)
VALUES ($1, 'true')
RETURNING id, username, status, created_at, updated_at
INSERT INTO alerte_policy (username, status, message)
VALUES ($1, 'true', $2)
RETURNING id, username, status, message, created_at, updated_at
`
var alert models.AlertPolicy
err := d.QueryRow(query, username).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := d.QueryRow(query, username, message).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return models.AlertPolicy{}, err
}
@@ -25,12 +25,12 @@ func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
WHERE id = $1
`
var alert models.AlertPolicy
err := d.QueryRow(query, id).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := d.QueryRow(query, id).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return models.AlertPolicy{}, err
}
@@ -42,7 +42,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
`
rows, err := d.Query(query)
@@ -54,7 +54,7 @@ func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
@@ -134,7 +134,7 @@ func (d *Database) ActivateAlert(id int) error {
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
WHERE status = 'true'
ORDER BY created_at DESC
@@ -148,7 +148,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
@@ -162,7 +162,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
WHERE username = $1
ORDER BY created_at DESC
@@ -176,7 +176,7 @@ func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, e
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"fmt"
"regexp"
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
CreatedAt string `json:"created_at"`
}
func ValidateCategoryColor(color string) error {
if color == "" {
return nil // valeur par défaut utilisée
}
if !hexColorRegex.MatchString(color) {
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
}
return nil
}
func (d *Database) GetAllCategories() ([]Category, error) {
rows, err := d.Query(`SELECT id, name, color, is_coming_soon, created_at FROM categories ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var categories []Category
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt); err != nil {
return nil, err
}
categories = append(categories, c)
}
if categories == nil {
categories = []Category{}
}
return categories, nil
}
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
err := d.QueryRow(
`INSERT INTO categories (name, color, is_coming_soon) VALUES ($1, $2, $3) RETURNING id, name, color, is_coming_soon, created_at`,
name, color, isComingSoon,
).Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
err := d.QueryRow(
`UPDATE categories SET name = $1, color = $2, is_coming_soon = $3 WHERE id = $4 RETURNING id, name, color, is_coming_soon, created_at`,
name, color, isComingSoon, id,
).Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) DeleteCategory(id int) error {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM products WHERE category = (SELECT name FROM categories WHERE id = $1)`, id).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
}
res, err := d.Exec(`DELETE FROM categories WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("catégorie non trouvée")
}
return nil
}
func (d *Database) CategoryExists(name string) (bool, error) {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM categories WHERE name = $1`, name).Scan(&count)
return count > 0, err
}
+120 -88
View File
@@ -57,7 +57,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
// GetAllClients récupère tous les clients
func (d *Database) GetAllClients() ([]*models.Client, error) {
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, referral_balance, created_at
FROM clients ORDER BY created_at DESC`
rows, err := d.Query(query)
@@ -80,6 +80,7 @@ func (d *Database) GetAllClients() ([]*models.Client, error) {
&client.Point,
&client.PointZipette,
&client.Amende,
&client.ReferralBalance,
&client.CreatedAt,
)
if err != nil {
@@ -212,7 +213,7 @@ func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error)
countQuery := `SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' OR status = 'support' OR status = 'livre' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'pending' OR status = 'livre' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as completed
FROM commandes WHERE username = $1`
@@ -856,128 +857,159 @@ func (d *Database) CalculateAndAddPointsForCommand(commandID int, username strin
return totalPoints, nil
}
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, error) {
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, string, error) {
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
// Charger les paramètres globaux
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
settings = DefaultSettings()
}
// Construire les sets de catégories par pool
weedCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesWeed {
weedCats[strings.ToLower(cat)] = true
}
zipetteCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesZipette {
zipetteCats[strings.ToLower(cat)] = true
}
// pool "total" → toujours compté dans le pool weed (point)
totalCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesTotal {
totalCats[strings.ToLower(cat)] = true
}
// Si aucune catégorie configurée → pas de points
if !settings.PointsSeparated {
// Mode non-séparé : seul le pool Total est actif
if len(weedCats) == 0 && len(zipetteCats) == 0 && len(totalCats) == 0 {
log.Printf("️ [CalcPointsTx] Mode non-séparé : aucune catégorie configurée → 0 points")
return 0, "", nil
}
} else {
// Mode séparé : seuls W et Z sont actifs (T ignoré)
if len(weedCats) == 0 && len(zipetteCats) == 0 {
log.Printf("️ [CalcPointsTx] Mode séparé : aucune catégorie W/Z configurée → 0 points")
return 0, "", nil
}
}
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
query := `
SELECT ci.quantite, ci.prix, COALESCE(p.category, 'weed_hash') as category
rows, err := tx.Query(`
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
FROM command_items ci
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = $1
`
rows, err := tx.Query(query, commandID)
`, commandID)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
return 0, fmt.Errorf("erreur récupération items: %w", err)
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
type ItemPoints struct {
Category string
Quantite float64
Prix float64
}
var items []ItemPoints
totalPrixWeedHash := 0.0
var itemCount int
totalPrixWeed := 0.0
totalPrixZipette := 0.0
totalPrixTotal := 0.0
for rows.Next() {
var item ItemPoints
if err := rows.Scan(&item.Quantite, &item.Prix, &item.Category); err != nil {
var quantite, prix float64
var category string
if err := rows.Scan(&quantite, &prix, &category); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur scan: %v", err)
return 0, fmt.Errorf("erreur lecture item: %w", err)
return 0, "", fmt.Errorf("erreur lecture item: %w", err)
}
items = append(items, item)
// Cumuler par catégorie
categoryLower := strings.ToLower(item.Category)
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
totalPrixZipette += item.Prix
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
// gros&semi → 0 points, on ne cumule pas
} else {
// weed_hash ou autres catégories
totalPrixWeedHash += item.Prix
itemCount++
catLower := strings.ToLower(category)
if weedCats[catLower] {
totalPrixWeed += prix
} else if zipetteCats[catLower] {
totalPrixZipette += prix
} else if totalCats[catLower] {
totalPrixTotal += prix
}
}
if err = rows.Err(); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur rows: %v", err)
return 0, fmt.Errorf("erreur itération items: %w", err)
return 0, "", fmt.Errorf("erreur itération items: %w", err)
}
if len(items) == 0 {
if itemCount == 0 {
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
return 0, nil
return 0, "", nil
}
log.Printf("📊 [CalcPointsTx] %d items - weed_hash: %.2f€, zipette: %.2f€",
len(items), totalPrixWeedHash, totalPrixZipette)
log.Printf("📊 [CalcPointsTx] %d items - weed: %.2f€, zipette: %.2f€, total: %.2f€",
itemCount, totalPrixWeed, totalPrixZipette, totalPrixTotal)
// ✅ ÉTAPE 2: Calculer les points par catégorie avec le bon barème
pointsWeedHash := 0
switch {
case totalPrixWeedHash >= 30 && totalPrixWeedHash <= 50:
pointsWeedHash = 1
case totalPrixWeedHash >= 60 && totalPrixWeedHash <= 150:
pointsWeedHash = 2
case totalPrixWeedHash >= 160 && totalPrixWeedHash <= 300:
pointsWeedHash = 3
case totalPrixWeedHash >= 310 && totalPrixWeedHash <= 400:
pointsWeedHash = 5
case totalPrixWeedHash >= 400:
pointsWeedHash = 10
}
// ✅ ÉTAPE 2: Calculer les points
var totalPoints int
var pointCategory string
var result sql.Result
pointsZipette := 0
switch {
case totalPrixZipette >= 30 && totalPrixZipette <= 100:
pointsZipette = 1
case totalPrixZipette >= 110 && totalPrixZipette <= 200:
pointsZipette = 2
case totalPrixZipette >= 210:
pointsZipette = 3
}
if !settings.PointsSeparated {
// ── Mode non-séparé : Barème Total appliqué sur W + Z + T ──────────────
allTotal := totalPrixWeed + totalPrixZipette + totalPrixTotal
totalPoints = CalcPointsFromTiers(allTotal, settings.PointsTotalTiers)
pointCategory = "total"
totalPoints := pointsWeedHash + pointsZipette
log.Printf("💰 [CalcPointsTx] Mode non-séparé - total: %.2f€ → %d pts (barème Total)",
allTotal, totalPoints)
log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d",
pointsWeedHash, pointsZipette, totalPoints)
if totalPoints == 0 {
return 0, "", nil
}
// ✅ ÉTAPE 3: Mettre à jour les points du client (dans la transaction)
if pointsWeedHash > 0 || pointsZipette > 0 {
updateQuery := `
result, err = tx.Exec(`
UPDATE clients
SET
point = point + $1,
point_zipette = point_zipette + $2,
updated_at = CURRENT_TIMESTAMP
SET point = point + $1, updated_at = CURRENT_TIMESTAMP
WHERE username = $2
`, totalPoints, username)
} else {
// ── Mode séparé : Barèmes W et Z, pool T ignoré ────────────────────────
pointsWeed := CalcPointsFromTiers(totalPrixWeed, settings.PointsWeedTiers)
pointsZipette := CalcPointsFromTiers(totalPrixZipette, settings.PointsZipetteTiers)
totalPoints = pointsWeed + pointsZipette
log.Printf("💰 [CalcPointsTx] Mode séparé - weed: %d pts, zipette: %d pts",
pointsWeed, pointsZipette)
if totalPoints == 0 {
return 0, "", nil
}
if pointsWeed > 0 && pointsZipette > 0 {
pointCategory = "mixed"
} else if pointsZipette > 0 {
pointCategory = "zipette&co"
} else {
pointCategory = "weed&hash"
}
result, err = tx.Exec(`
UPDATE clients
SET point = point + $1, point_zipette = point_zipette + $2, updated_at = CURRENT_TIMESTAMP
WHERE username = $3
`
result, err := tx.Exec(updateQuery, pointsWeedHash, pointsZipette, username)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points: %v", err)
return 0, fmt.Errorf("erreur mise à jour points: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
log.Printf("⚠️ [CalcPointsTx] Client %s non trouvé", username)
return 0, fmt.Errorf("client non trouvé")
}
log.Printf("✅ [CalcPointsTx] Points ajoutés: +%d weed_hash, +%d zipette pour %s",
pointsWeedHash, pointsZipette, username)
`, pointsWeed, pointsZipette, username)
}
log.Printf("🎉 [CalcPointsTx] SUCCÈS - Total %d points attribués", totalPoints)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points: %v", err)
return 0, "", fmt.Errorf("erreur mise à jour points: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
log.Printf("⚠️ [CalcPointsTx] Client %s non trouvé", username)
return 0, "", fmt.Errorf("client non trouvé")
}
return totalPoints, nil
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
return totalPoints, pointCategory, nil
}
func (d *Database) CanUserAccessCommand(
+4 -1
View File
@@ -213,6 +213,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
c.status as command_status,
c.adresse as command_address,
c.total_prix,
c.referral_used,
c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, 'weed_hash') as category
@@ -241,13 +242,14 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
var createdAt, updatedAt, commandCreatedAt time.Time
var commandStatus, commandAddress, livreurAssign sql.NullString
var category string
var referralUsed float64
// ✅ FIX: Utiliser &productID (sql.NullInt64)
err := rows.Scan(
&id, &commandID, &produit, &productID, &quantite, &prix,
&clientUsername, &clientNom, &clientPrenom, &clientTelephone, &deliveryAddress, &status,
&createdAt, &updatedAt,
&commandStatus, &commandAddress, &totalPrix, &livreurAssign, &commandCreatedAt,
&commandStatus, &commandAddress, &totalPrix, &referralUsed, &livreurAssign, &commandCreatedAt,
&category,
)
@@ -281,6 +283,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
"command_status": commandStatus.String,
"command_address": commandAddress.String,
"total_prix": totalPrix,
"referral_used": referralUsed,
"livreur_assign": livreurAssign.String,
"command_created_at": commandCreatedAt,
"category": category,
+1 -1
View File
@@ -26,7 +26,7 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
// Filtrer par status
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
+40 -34
View File
@@ -53,7 +53,6 @@ func validateCommandStatus(status string) error {
"approved": true,
"cancelled": true,
"disabled": true,
"support": true,
}
if !validStatuses[status] {
@@ -423,10 +422,15 @@ func (d *Database) GetCommandCount() (int, error) {
return count, nil
}
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
_, err := d.Exec(`UPDATE commandes SET referral_used = $1 WHERE id = $2`, amount, commandID)
return err
}
func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
// ✅ Déjà sécurisé avec paramètre $1
query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at,
proposed_address, address_proposal_status
proposed_address, address_proposal_status, referral_used
FROM commandes WHERE id = $1`
var commandID int
@@ -434,7 +438,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
var livreurAssign sql.NullString
var proposedAddress sql.NullString
var addressProposalStatus string
var totalPrix float64
var totalPrix, referralUsed float64
var createdAt, updatedAt time.Time
err := d.QueryRow(query, id).Scan(
@@ -448,6 +452,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
&updatedAt,
&proposedAddress,
&addressProposalStatus,
&referralUsed,
)
if err == sql.ErrNoRows {
@@ -466,6 +471,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
"created_at": createdAt,
"updated_at": updatedAt,
"address_proposal_status": addressProposalStatus,
"referral_used": referralUsed,
}
if livreurAssign.Valid {
@@ -595,7 +601,7 @@ func (d *Database) RespondToAddressProposal(commandID int, clientUsername string
// UpdateCommandStatus met à jour le statut d'une commande
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
// ✅ SÉCURITÉ: Validation du statut
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
@@ -706,7 +712,7 @@ func (d *Database) GetCommandsWithFilter(status, username string, excludeApprove
// ✅ Filtrer par status si fourni avec validation
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
@@ -832,7 +838,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
currentStatus, cmdUsername, livreurAssign)
// ✅ ÉTAPE 3: Vérifier que le statut permet la validation
validStatuses := []string{"assigned", "en_route", "pending", "support", "livre"}
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
isValid := false
for _, s := range validStatuses {
if currentStatus == s {
@@ -878,7 +884,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmdUsername)
// Utiliser la version transactionnelle du calcul de points
points, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmdUsername)
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmdUsername)
if err != nil {
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
return 0, fmt.Errorf("erreur attribution points: %w", err)
@@ -957,14 +963,14 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
}
// ApproveDeliveryAtomic - Version atomique pour approbation client
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, error) {
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
// ✅ ÉTAPE 1: Démarrer une transaction
tx, err := d.Begin()
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur début transaction: %v", err)
return 0, fmt.Errorf("erreur transaction: %w", err)
return 0, "", fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
@@ -979,11 +985,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if err == sql.ErrNoRows {
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
return 0, fmt.Errorf("commande non trouvée")
return 0, "", fmt.Errorf("commande non trouvée")
}
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
return 0, fmt.Errorf("erreur lecture commande: %w", err)
return 0, "", fmt.Errorf("erreur lecture commande: %w", err)
}
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", currentStatus, cmdUsername)
@@ -992,13 +998,13 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if cmdUsername != username {
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
username, cmdUsername)
return 0, fmt.Errorf("cette commande ne vous appartient pas")
return 0, "", fmt.Errorf("cette commande ne vous appartient pas")
}
// ✅ ÉTAPE 4: Vérifier le statut
if currentStatus != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", currentStatus)
return 0, fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
}
// ✅ ÉTAPE 5: UPDATE avec vérification du statut
@@ -1010,25 +1016,25 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", err)
return 0, fmt.Errorf("erreur mise à jour statut: %w", err)
return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return 0, fmt.Errorf("commande déjà approuvée ou modifiée")
return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée")
}
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
// ✅ ÉTAPE 6: Calculer et ajouter les points
totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
totalPoints, pointCategory, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
return 0, fmt.Errorf("erreur attribution points: %w", err)
return 0, "", fmt.Errorf("erreur attribution points: %w", err)
}
log.Printf("✅ [ApproveAtomic] %d points attribués à %s", totalPoints, username)
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
// ✅ ÉTAPE 7: Incrémenter le compteur de commandes
_, err = tx.Exec(`
@@ -1046,7 +1052,7 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
`, commandID, "approved",
fmt.Sprintf("Livraison confirmée par le client %s - %d points attribués", username, totalPoints),
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
username)
if err != nil {
@@ -1056,11 +1062,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
// ✅ ÉTAPE 9: Commit
if err := tx.Commit(); err != nil {
log.Printf("❌ [ApproveAtomic] Erreur COMMIT: %v", err)
return 0, fmt.Errorf("erreur commit transaction: %w", err)
return 0, "", fmt.Errorf("erreur commit transaction: %w", err)
}
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points attribués",
commandID, totalPoints)
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
commandID, totalPoints, pointCategory)
// ✅ ÉTAPE 10: Optimiser queue livreur (async, après commit)
if livreurAssign != "" {
@@ -1087,16 +1093,16 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
log.Printf("✅ [ApproveAtomic] Caches invalidés")
}()
return totalPoints, nil
return totalPoints, pointCategory, nil
}
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, error) {
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
tx, err := d.Begin()
if err != nil {
return 0, "", fmt.Errorf("erreur transaction: %w", err)
return 0, "", "", fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
@@ -1109,14 +1115,14 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
`, commandID).Scan(&currentStatus, &clientUsername, &livreurAssign)
if err == sql.ErrNoRows {
return 0, "", fmt.Errorf("commande non trouvée")
return 0, "", "", fmt.Errorf("commande non trouvée")
}
if err != nil {
return 0, "", fmt.Errorf("erreur lecture commande: %w", err)
return 0, "", "", fmt.Errorf("erreur lecture commande: %w", err)
}
if currentStatus != "livre" {
return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
return 0, "", "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
}
result, err := tx.Exec(`
@@ -1125,17 +1131,17 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
WHERE id = $1 AND status = 'livre'
`, commandID)
if err != nil {
return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err)
return 0, "", "", fmt.Errorf("erreur mise à jour statut: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée")
return 0, "", "", fmt.Errorf("commande déjà approuvée ou modifiée")
}
totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername)
totalPoints, pointCategory, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername)
if err != nil {
return 0, "", fmt.Errorf("erreur attribution points: %w", err)
return 0, "", "", fmt.Errorf("erreur attribution points: %w", err)
}
_, err = tx.Exec(`
@@ -1158,7 +1164,7 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
}
if err := tx.Commit(); err != nil {
return 0, "", fmt.Errorf("erreur commit: %w", err)
return 0, "", "", fmt.Errorf("erreur commit: %w", err)
}
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
@@ -1178,5 +1184,5 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsername))
}()
return totalPoints, clientUsername, nil
return totalPoints, pointCategory, clientUsername, nil
}
+7 -7
View File
@@ -111,7 +111,7 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}
// ✅ Vérifier le statut
validStatusesForAssignment := []string{"pending", "support"}
validStatusesForAssignment := []string{"pending"}
isValidStatus := false
for _, vs := range validStatusesForAssignment {
if currentStatus == vs {
@@ -132,10 +132,10 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
// ============================================
updateQuery := `UPDATE commandes
SET livreur_assign = $1,
status = 'support',
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND status IN ('pending', 'support')
AND status IN ('pending')
AND (livreur_assign IS NULL OR livreur_assign = '' OR livreur_assign = $1)`
result, err := tx.Exec(updateQuery, livreurUsername, commandID)
@@ -159,10 +159,10 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
logQuery := `INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "support",
_, err = tx.Exec(logQuery, commandID, "assigned",
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
"admin")
if err != nil {
@@ -180,7 +180,7 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}
log.Printf("🎉 [AssignDeliveryPerson] SUCCÈS - Commande %d assignée à %s", commandID, livreurUsername)
log.Printf(" Workflow: pending → ✅ support (TRANSACTION COMMITTED)")
log.Printf(" Workflow: pending → ✅ assigned (TRANSACTION COMMITTED)")
return nil
}
@@ -356,7 +356,7 @@ func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
logQuery := `INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "approved",
-1
View File
@@ -84,7 +84,6 @@ func ValidateStatuses(statuses string) ([]string, error) {
// Liste blanche complète
validStatusMap := map[string]bool{
"pending": true,
"support": true,
"assigned": true,
"en_route": true,
"arrived": true,
+40
View File
@@ -133,6 +133,34 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
}
// Migration: ajouter colonne color pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
log.Fatalf("❌ Erreur migration categories.color: %v", err)
}
// Migration: ajouter colonne is_coming_soon pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
}
// Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
)`); err != nil {
log.Fatalf("❌ Erreur migration app_settings: %v", err)
}
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
}
// Migration: montant de parrainage utilisé pour la commande
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically()
@@ -178,9 +206,11 @@ func (db *Database) createTables() error {
cancel_commande INTEGER DEFAULT 0,
cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS referral_balance NUMERIC(10,2) DEFAULT 0.0;`,
// ============================
// TABLE jwt_tokens
@@ -195,6 +225,16 @@ func (db *Database) createTables() error {
CHECK (date_fin > date_save)
);`,
// ============================
// TABLE categories
// ============================
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE products
// ============================
+41
View File
@@ -131,6 +131,47 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
return nil
}
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
// et envoie un push aux ceux qui ont un token. Appelé dès qu'une nouvelle commande est créée.
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
rows, err := d.Query(
`SELECT username, COALESCE(push_token, '') FROM users WHERE role IN ('admin','cabine')`,
)
if err != nil {
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
defer rows.Close()
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
notification := map[string]interface{}{
"command_id": commandID,
"type": "new_order",
"message": msg,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
sent := 0
for rows.Next() {
var username, token string
if err := rows.Scan(&username, &token); err != nil {
continue
}
notifKey := fmt.Sprintf("notifications:%s", username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
if token != "" {
go sendExpoPushWithChannel(token, "Nouvelle commande", msg, commandID, "new_order", "orders")
sent++
}
}
log.Printf("📬 [ADMIN_NOTIF] Notif Redis + push (%d tokens) pour commande #%d", sent, commandID)
}
// AddDeliveryRating ajoute une note pour un livreur
func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error {
query := `
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"database/sql"
"fmt"
)
// GetClientReferralBalance retourne le solde parrainage d'un client.
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
var balance float64
err := d.QueryRow(
`SELECT referral_balance FROM clients WHERE username = $1`,
username,
).Scan(&balance)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("client non trouvé")
}
return balance, err
}
// CreditClientReferral ajoute un montant au solde parrainage d'un client.
func (d *Database) CreditClientReferral(username string, amount float64) error {
if amount <= 0 {
return fmt.Errorf("le montant doit être positif")
}
res, err := d.Exec(
`UPDATE clients SET referral_balance = referral_balance + $1 WHERE username = $2`,
amount, username,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UseClientReferralBalance déduit un montant du solde parrainage dans une transaction.
// Retourne une erreur si le solde est insuffisant.
func (d *Database) UseClientReferralBalance(tx *sql.Tx, username string, amount float64) error {
if amount <= 0 {
return nil
}
var balance float64
err := tx.QueryRow(
`SELECT referral_balance FROM clients WHERE username = $1 FOR UPDATE`,
username,
).Scan(&balance)
if err != nil {
return fmt.Errorf("client non trouvé")
}
if balance < amount {
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
}
_, err = tx.Exec(
`UPDATE clients SET referral_balance = referral_balance - $1 WHERE username = $2`,
amount, username,
)
return err
}
+285
View File
@@ -0,0 +1,285 @@
package db
import (
"encoding/json"
"fmt"
)
// DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct {
Enabled bool `json:"enabled"`
OpenTime string `json:"open_time"` // ex: "09:00"
CloseTime string `json:"close_time"` // ex: "20:00"
}
// DeliverySchedule représente les horaires de livraison pour chaque jour
type DeliverySchedule struct {
Monday DaySchedule `json:"monday"`
Tuesday DaySchedule `json:"tuesday"`
Wednesday DaySchedule `json:"wednesday"`
Thursday DaySchedule `json:"thursday"`
Friday DaySchedule `json:"friday"`
Saturday DaySchedule `json:"saturday"`
Sunday DaySchedule `json:"sunday"`
}
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
func DefaultDeliverySchedule() DeliverySchedule {
day := DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
return DeliverySchedule{
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
Friday: day, Saturday: day, Sunday: day,
}
}
// PostalZone représente une zone de livraison avec un minimum de commande
type PostalZone struct {
Name string `json:"name"`
MinAmount float64 `json:"min_amount"`
Codes []string `json:"codes"`
}
// PointsTier représente un palier du barème de points
// Si Max == 0, il n'y a pas de borne supérieure (illimité)
type PointsTier struct {
Min float64 `json:"min"`
Max float64 `json:"max"` // 0 = illimité
Points int `json:"points"`
}
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
func CalcPointsFromTiers(total float64, tiers []PointsTier) int {
for _, t := range tiers {
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
return t.Points
}
}
return 0
}
// AppSettings contient les paramètres globaux de l'application
type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsCategoriesWeed []string `json:"points_categories_weed"` // catégories → pool point (weed)
PointsCategoriesZipette []string `json:"points_categories_zipette"` // catégories → pool point_zipette
PointsCategoriesTotal []string `json:"points_categories_total"` // catégories → pool total (point, sans séparation)
PointsSeparated bool `json:"points_separated"` // true = weed/zipette séparés, false = tout dans point
// Barème de points par paliers configurables
PointsWeedTiers []PointsTier `json:"points_weed_tiers"`
PointsZipetteTiers []PointsTier `json:"points_zipette_tiers"`
PointsTotalTiers []PointsTier `json:"points_total_tiers"`
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
}
// DefaultSettings retourne les paramètres par défaut
func DefaultSettings() AppSettings {
return AppSettings{
PenaltiesEnabled: true,
ShowAmendeScore: true,
PointsEnabled: true,
ReferralEnabled: true,
PointsCategoriesWeed: []string{},
PointsCategoriesZipette: []string{},
PointsCategoriesTotal: []string{},
PointsSeparated: true,
PointsWeedTiers: []PointsTier{
{Min: 30, Max: 50, Points: 1},
{Min: 60, Max: 150, Points: 2},
{Min: 160, Max: 300, Points: 3},
{Min: 310, Max: 400, Points: 5},
{Min: 401, Max: 0, Points: 10},
},
PointsZipetteTiers: []PointsTier{
{Min: 30, Max: 100, Points: 1},
{Min: 110, Max: 200, Points: 2},
{Min: 210, Max: 0, Points: 3},
},
PointsTotalTiers: []PointsTier{},
DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
"44400", "44880", "44120", "44230", "44115",
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
}},
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
"44860", "44220", "44118", "44710", "44690", "44119",
}},
},
}
}
// GetSettings récupère les paramètres depuis la DB
func (d *Database) GetSettings() (AppSettings, error) {
settings := DefaultSettings()
rows, err := d.Query(`SELECT key, value FROM app_settings`)
if err != nil {
return settings, fmt.Errorf("erreur lecture settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
continue
}
switch key {
case "penalties_enabled":
settings.PenaltiesEnabled = value == "true"
case "show_amende_score":
settings.ShowAmendeScore = value == "true"
case "points_enabled":
settings.PointsEnabled = value == "true"
case "points_categories_weed":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesWeed = cats
}
case "points_categories_zipette":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesZipette = cats
}
case "points_categories_total":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesTotal = cats
}
case "points_separated":
settings.PointsSeparated = value == "true"
case "points_weed_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsWeedTiers = tiers
}
case "points_zipette_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsZipetteTiers = tiers
}
case "points_total_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsTotalTiers = tiers
}
case "referral_enabled":
settings.ReferralEnabled = value == "true"
case "delivery_schedule":
var sched DeliverySchedule
if err := json.Unmarshal([]byte(value), &sched); err == nil {
settings.DeliverySchedule = sched
}
case "postal_zones":
var zones []PostalZone
if err := json.Unmarshal([]byte(value), &zones); err == nil {
settings.PostalZones = zones
}
}
}
return settings, rows.Err()
}
// UpdateSettings sauvegarde les paramètres dans la DB
func (d *Database) UpdateSettings(s AppSettings) error {
boolStr := func(b bool) string {
if b {
return "true"
}
return "false"
}
if s.PointsCategoriesWeed == nil {
s.PointsCategoriesWeed = []string{}
}
if s.PointsCategoriesZipette == nil {
s.PointsCategoriesZipette = []string{}
}
if s.PointsCategoriesTotal == nil {
s.PointsCategoriesTotal = []string{}
}
weedJSON, err := json.Marshal(s.PointsCategoriesWeed)
if err != nil {
return fmt.Errorf("erreur sérialisation weed: %w", err)
}
zipetteJSON, err := json.Marshal(s.PointsCategoriesZipette)
if err != nil {
return fmt.Errorf("erreur sérialisation zipette: %w", err)
}
totalJSON, err := json.Marshal(s.PointsCategoriesTotal)
if err != nil {
return fmt.Errorf("erreur sérialisation total: %w", err)
}
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
upsert := `INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
if s.PointsWeedTiers == nil {
s.PointsWeedTiers = []PointsTier{}
}
if s.PointsZipetteTiers == nil {
s.PointsZipetteTiers = []PointsTier{}
}
if s.PointsTotalTiers == nil {
s.PointsTotalTiers = []PointsTier{}
}
weedTiersJSON, err := json.Marshal(s.PointsWeedTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation weed tiers: %w", err)
}
zipetteTiersJSON, err := json.Marshal(s.PointsZipetteTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation zipette tiers: %w", err)
}
totalTiersJSON, err := json.Marshal(s.PointsTotalTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation total tiers: %w", err)
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_categories_weed", string(weedJSON)},
{"points_categories_zipette", string(zipetteJSON)},
{"points_categories_total", string(totalJSON)},
{"points_separated", boolStr(s.PointsSeparated)},
{"points_weed_tiers", string(weedTiersJSON)},
{"points_zipette_tiers", string(zipetteTiersJSON)},
{"points_total_tiers", string(totalTiersJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
}
schedJSON, err := json.Marshal(s.DeliverySchedule)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
}
pairs = append(pairs, [2]string{"delivery_schedule", string(schedJSON)})
if s.PostalZones == nil {
s.PostalZones = []PostalZone{}
}
zonesJSON, err := json.Marshal(s.PostalZones)
if err != nil {
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
}
pairs = append(pairs, [2]string{"postal_zones", string(zonesJSON)})
for _, p := range pairs {
if _, err = tx.Exec(upsert, p[0], p[1]); err != nil {
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
}
}
return tx.Commit()
}
+3 -3
View File
@@ -11,7 +11,7 @@ func AddAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
@@ -37,7 +37,7 @@ func DeleteAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
@@ -61,7 +61,7 @@ func DeleteAddress(c *gin.Context) {
func GetAllAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
+8 -1
View File
@@ -22,8 +22,15 @@ func AlertPolice(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
var req struct {
Message string `json:"message"`
}
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req)
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr)
alert, err := database.CreateAlert(usernameStr, req.Message)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1
View File
@@ -738,6 +738,7 @@ func GetAllClients(c *gin.Context) {
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
"referral_balance": cl.ReferralBalance,
})
}
+3 -3
View File
@@ -528,8 +528,8 @@ func AddDeliverySupport(c *gin.Context) {
err = database.AddCommandLog(
commandID,
"support",
fmt.Sprintf("Support cabine: %s", req.Message),
"note",
fmt.Sprintf("Note cabine: %s", req.Message),
cabineUsername.(string),
)
@@ -630,7 +630,7 @@ func ForceValidateDelivery(c *gin.Context) {
return
}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "support", "pending", "priority"}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "pending", "priority"}
isValidStatus := false
for _, vs := range validStatuses {
if status == vs {
+136
View File
@@ -0,0 +1,136 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// GET /api/v1/categories — public
func GetCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
categories, err := database.GetAllCategories()
if err != nil {
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"categories": categories,
})
}
// POST /api/v2/admin/protected/categories — admin
func CreateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
return
}
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"category": category,
})
}
// PUT /api/v2/admin/protected/categories/:id — admin
func UpdateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
return
}
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
c.JSON(http.StatusOK, gin.H{
"success": true,
"category": category,
})
}
// DELETE /api/v2/admin/protected/categories/:id — admin
func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.DeleteCategory(id); err != nil {
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
if strings.Contains(err.Error(), "utilisée par") {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
}
return
}
log.Printf("✅ [CATEGORIES] Supprimée: %d", id)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
}
@@ -1,8 +1,3 @@
// ============================================
// handlers/client_tracking.go - NOUVEAU FICHIER
// SUIVI COMMANDE POUR CLIENTS
// ============================================
package handlers
import (
@@ -208,7 +203,6 @@ func getStatusMessage(status string) string {
messages := map[string]string{
"pending": "⏳ En attente d'assignation",
"assigned": "✅ Livreur assigné",
"support": "👨‍💼 En préparation",
"en_route": "🚗 En cours de livraison",
"arrived": "📍 Livreur arrivé",
"livre": "📦 Livré - En attente de confirmation",
@@ -250,7 +244,6 @@ func getStatusIcon(status string) string {
icons := map[string]string{
"created": "🛒",
"assigned": "👤",
"support": "📦",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
+24 -352
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/commands_handlers_CORRIGES.go
// ============================================
// ⚠️ CreateCommandFromBasket SUPPRIMÉ (utiliser ValidateBasket à la place)
package handlers
import (
@@ -12,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -19,14 +15,17 @@ import (
var (
rateLimitMap = make(map[string][]time.Time)
rateLimitMu sync.Mutex
maxRequests = 10
timeWindow = time.Minute
)
func checkRateLimit(key string) bool {
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
if timestamps, exists := rateLimitMap[key]; exists {
// Nettoyer les anciennes entrées
var validTimestamps []time.Time
for _, ts := range timestamps {
if now.Sub(ts) < timeWindow {
@@ -399,7 +398,7 @@ func ApproveDelivery(c *gin.Context) {
// ✅ TRANSACTION ATOMIQUE dans la DB pour éviter race condition
// Cette fonction doit être créée dans le fichier db
totalPoints, err := database.ApproveDeliveryAtomic(commandID, username)
totalPoints, pointCategory, err := database.ApproveDeliveryAtomic(commandID, username)
if err != nil {
log.Printf("❌ [APPROVE] Erreur: %v", err)
// ❌ Ne pas exposer les détails de l'erreur
@@ -409,13 +408,14 @@ func ApproveDelivery(c *gin.Context) {
return
}
log.Printf("✅ [APPROVE] %d points attribués à %s", totalPoints, username)
log.Printf("✅ [APPROVE] %d points attribués à %s (catégorie: %s)", totalPoints, username, pointCategory)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison confirmée",
"command_id": commandID,
"points_earned": totalPoints,
"category": pointCategory,
})
}
@@ -446,14 +446,14 @@ func StaffApproveDelivery(c *gin.Context) {
log.Printf("✅ [STAFF_APPROVE] %s (%s) confirme réception cmd %d", staffUsername, role, commandID)
totalPoints, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername)
totalPoints, pointCategory, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername)
if err != nil {
log.Printf("❌ [STAFF_APPROVE] Erreur: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de confirmer la réception: " + err.Error()})
return
}
log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s", totalPoints, clientUsername)
log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s (catégorie: %s)", totalPoints, clientUsername, pointCategory)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -461,6 +461,7 @@ func StaffApproveDelivery(c *gin.Context) {
"command_id": commandID,
"client_username": clientUsername,
"points_earned": totalPoints,
"category": pointCategory,
})
}
@@ -542,7 +543,7 @@ func ValidateDelivery(c *gin.Context) {
currentStatus, _ := command["status"].(string)
validStatuses := []string{"assigned", "en_route", "pending", "support", "livre"}
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
isValid := false
for _, s := range validStatuses {
if currentStatus == s {
@@ -674,7 +675,7 @@ func AssignDeliveryPerson(c *gin.Context) {
return
}
database.AddCommandLog(commandID, "support",
database.AddCommandLog(commandID, "assigned",
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
staffUsername.(string))
@@ -689,131 +690,6 @@ func AssignDeliveryPerson(c *gin.Context) {
})
}
// DisableCommands désactive une ou plusieurs commandes
// POST /api/v1/admin/commands/disable
func DisableCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
CommandIDs []int `json:"command_ids" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
if len(req.CommandIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Aucun ID de commande fourni",
})
return
}
adminUsername, exists := c.Get("username")
if !exists {
adminUsername = "admin"
}
log.Printf("❌ [DISABLE] Désactivation de %d commande(s) par %s", len(req.CommandIDs), adminUsername)
disabledCount := 0
failedCount := 0
errors := []string{}
for _, commandID := range req.CommandIDs {
command, err := database.GetCommandByID(commandID)
if err != nil {
errors = append(errors, "Commande "+strconv.Itoa(commandID)+" non trouvée")
failedCount++
continue
}
currentStatus := command["status"].(string)
if currentStatus == "disabled" {
errors = append(errors, "Commande "+strconv.Itoa(commandID)+" déjà désactivée")
failedCount++
continue
}
err = database.UpdateCommandStatus(commandID, "disabled")
if err != nil {
errors = append(errors, "Erreur désactivation cmd "+strconv.Itoa(commandID))
failedCount++
continue
}
reason := req.Reason
if reason == "" {
reason = "Désactivée par admin"
}
database.AddCommandLog(commandID, "disabled", reason, adminUsername.(string))
disabledCount++
log.Printf("✅ [DISABLE] Commande %d désactivée", commandID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Traitement des commandes terminé",
"disabled_count": disabledCount,
"failed_count": failedCount,
"errors": errors,
})
}
// ============================================
// COMMANDES CLIENT (My Orders)
// ============================================
// GetMyCommands récupère les commandes du client authentifié
// GET /api/v1/my-commands?status=pending
func GetMyCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [MY_CMDS] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Utilisateur non authentifié",
})
return
}
usernameStr := username.(string)
status := c.Query("status")
// ✅ NOUVEAU: Paramètre pour exclure les commandes approved
excludeApproved := c.Query("exclude_approved") == "true"
log.Printf("📋 [MY_CMDS] Récupération pour %s (status=%s, exclude_approved=%v)",
usernameStr, status, excludeApproved)
// ✅ Utiliser la nouvelle fonction avec filtrage
commands, err := database.GetCommandsWithFilter(status, usernameStr, excludeApproved)
if err != nil {
log.Printf("❌ [MY_CMDS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des commandes",
"details": err.Error(),
})
return
}
log.Printf("✅ [MY_CMDS] Trouvées: %d commandes", len(commands))
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": commands,
"count": len(commands),
})
}
func GetClientCommandsHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -924,9 +800,9 @@ func NotifyClientToDescend(c *gin.Context) {
log.Printf("🔔 [NOTIFY] Client %s notifié pour commande %d par %s", clientUsername, commandID, staffUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Client notifié",
"client_username": clientUsername,
"success": true,
"message": "Client notifié",
"client_username": clientUsername,
})
}
@@ -989,12 +865,13 @@ func ShowItems(c *gin.Context) {
log.Printf("✅ [ITEMS] %d items récupérés", len(items))
commandInfo := map[string]interface{}{
"id": items[0]["command_id"],
"status": items[0]["command_status"],
"address": items[0]["command_address"],
"total_prix": items[0]["total_prix"],
"livreur": items[0]["livreur_assign"],
"created_at": items[0]["command_created_at"],
"id": items[0]["command_id"],
"status": items[0]["command_status"],
"address": items[0]["command_address"],
"total_prix": items[0]["total_prix"],
"referral_used": items[0]["referral_used"],
"livreur": items[0]["livreur_assign"],
"created_at": items[0]["command_created_at"],
}
clientInfo := map[string]interface{}{
@@ -1098,90 +975,6 @@ func GetCommandItemsWithDetails(c *gin.Context) {
})
}
// GetClientCommandsItems récupère tous les items du client
// GET /api/v1/clients/:username/commands/items
func GetClientCommandsItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
if username == "" {
log.Printf("❌ [CLIENT_ITEMS] Username manquant")
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("📦 [CLIENT_ITEMS] Récupération pour: %s", username)
client, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [CLIENT_ITEMS] Client non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
items, err := database.GetCommandItemsByUsername(username)
if err != nil {
log.Printf("❌ [CLIENT_ITEMS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des items",
"details": err.Error(),
})
return
}
commandsMap := make(map[int][]map[string]interface{})
var commandIDs []int
for _, item := range items {
cmdID := int(item["command_id"].(float64))
if _, exists := commandsMap[cmdID]; !exists {
commandIDs = append(commandIDs, cmdID)
}
commandsMap[cmdID] = append(commandsMap[cmdID], item)
}
log.Printf("✅ [CLIENT_ITEMS] %d items dans %d commandes", len(items), len(commandsMap))
type CommandGroup struct {
CommandID int `json:"command_id"`
Status string `json:"status"`
Address string `json:"address"`
TotalPrice float64 `json:"total_price"`
LivreurAssign string `json:"livreur_assign"`
CreatedAt string `json:"created_at"`
ItemsCount int `json:"items_count"`
Items []map[string]interface{} `json:"items"`
}
var commandGroups []CommandGroup
for _, cmdID := range commandIDs {
items := commandsMap[cmdID]
if len(items) > 0 {
group := CommandGroup{
CommandID: cmdID,
Status: items[0]["command_status"].(string),
Address: items[0]["command_address"].(string),
TotalPrice: items[0]["total_prix"].(float64),
LivreurAssign: fmt.Sprintf("%v", items[0]["livreur_assign"]),
CreatedAt: items[0]["command_created_at"].(string),
ItemsCount: len(items),
Items: items,
}
commandGroups = append(commandGroups, group)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"client": client.Username,
"client_name": fmt.Sprintf("%s %s", client.Prenom, client.Nom),
"phone": client.Telephone,
"total_items": len(items),
"total_commands": len(commandGroups),
"commands": commandGroups,
})
}
// UpdateItemStatus met à jour le statut d'un item
// PUT /api/v1/admin/items/:item_id/status
func UpdateItemStatus(c *gin.Context) {
@@ -1209,7 +1002,7 @@ func UpdateItemStatus(c *gin.Context) {
log.Printf("📝 [UPD_ITEM] Mise à jour: item=%d, status=%s", itemID, req.Status)
validStatuses := []string{"pending", "preparing", "ready", "shipped", "delivered"}
validStatuses := []string{"pending", "preparing", "delivered"}
isValid := false
for _, vs := range validStatuses {
if req.Status == vs {
@@ -1248,65 +1041,6 @@ func UpdateItemStatus(c *gin.Context) {
})
}
// GetCommandFullDetails récupère tous les détails d'une commande
// GET /api/v1/admin/commands/:id/full-details
func GetCommandFullDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [FULL_DETAILS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé - Admin uniquement"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
log.Printf("📋 [FULL_DETAILS] Récupération complète: cmd %d", commandID)
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [FULL_DETAILS] Non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
items, _ := database.GetCommandItems(commandID)
logs, _ := database.GetCommandLogs(commandID)
var clientFullInfo map[string]interface{}
if username, ok := command["username"].(string); ok {
if client, err := database.GetClientByUsername(username); err == nil {
clientFullInfo = map[string]interface{}{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
"created_at": client.CreatedAt,
}
}
}
log.Printf("✅ [FULL_DETAILS] Récupéré: %d items, %d logs", len(items), len(logs))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"client_info": clientFullInfo,
"items": items,
"items_count": len(items),
"logs": logs,
"logs_count": len(logs),
})
}
// DeleteCommandItem supprime un item d'une commande
// DELETE /api/v2/admin/protected/orders/:id/items/:item_id
func DeleteCommandItem(c *gin.Context) {
@@ -1404,65 +1138,3 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
"new_status": req.Status,
})
}
// GetCommandItemsStats récupère les stats d'une commande
// GET /api/v1/commands/:id/stats
func GetCommandItemsStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("📊 [STATS] Calcul stats: cmd %d", commandID)
items, err := database.GetCommandItems(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(items) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"items_count": 0,
"total_items": 0,
"total_price": 0,
"avg_price": 0,
})
return
}
totalItems := 0
totalPrice := 0.0
statusCounts := make(map[string]int)
for _, item := range items {
quantite := int(item["quantite"].(float64))
prix := item["prix"].(float64)
status := item["status"].(string)
totalItems += quantite
totalPrice += prix * float64(quantite)
statusCounts[status]++
}
avgPrice := 0.0
if len(items) > 0 {
avgPrice = totalPrice / float64(len(items))
}
log.Printf("✅ [STATS] Items=%d, Total=%.2f€", totalItems, totalPrice)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"items_count": len(items),
"total_items": totalItems,
"total_price": totalPrice,
"avg_price": avgPrice,
"status_breakdown": statusCounts,
})
}
+18 -14
View File
@@ -158,15 +158,16 @@ func GetDeliveryDetails(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"delivery": gin.H{
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"referral_used": command["referral_used"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
},
})
}
@@ -226,8 +227,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
// ✅ STATUTS VALIDES POUR LIVREUR (correspondant à la DB)
validStatuses := []string{
"support", // Prise en charge
"assigned", // Assigné (si auto-assignation)
"assigned", // Assigné
"en_route", // En route vers le client
"arrived", // Arrivé à destination
"livre", // Livré (en attente confirmation client)
@@ -364,8 +364,6 @@ func UpdateDeliveryStatus(c *gin.Context) {
if clientUsername != "" {
var clientMsg string
switch req.Status {
case "support":
clientMsg = fmt.Sprintf("Votre commande #%d est prise en charge", commandID)
case "en_route":
if etaMinutes > 0 {
clientMsg = fmt.Sprintf("Votre commande #%d est en route ! Arrivée dans ~%d min", commandID, etaMinutes)
@@ -378,6 +376,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
case "failed":
clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID)
case "cancelled":
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", commandID)
}
if clientMsg != "" {
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
@@ -404,6 +404,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
usernameStr,
)
case "cancelled":
// Annulation par le livreur - Nettoyer la queue
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
@@ -452,7 +457,6 @@ func degreesToRadians(degrees float64) float64 {
func getDeliveryStatusMessage(status string) string {
messages := map[string]string{
"support": "Prise en charge de la livraison",
"assigned": "Commande assignée",
"en_route": "En route vers le client",
"arrived": "Arrivé à destination",
+6 -15
View File
@@ -106,28 +106,19 @@ func GetOrderETA(c *gin.Context) {
return
}
// ✅ CORRECTION CRITIQUE: Vérifier si le livreur a démarré
if cmdStatus != "en_route" && cmdStatus != "arrived" {
log.Printf("⏳ [ETA] Commande en statut '%s' - ETA pas encore disponible", cmdStatus)
livreurAssign, _ := command["livreur_assign"].(string)
var livreurInfo string
if livreurAssign != "" {
livreurInfo = fmt.Sprintf("Livreur %s assigné", livreurAssign)
} else {
livreurInfo = "En attente d'assignation"
}
// Pour pending: aucune estimation disponible
if cmdStatus == "pending" {
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "Le livreur n'a pas encore démarré la livraison",
"eta_available": false,
"info": livreurInfo,
"message": "En attente d'assignation d'un livreur",
})
return
}
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID)
@@ -140,7 +131,7 @@ func GetOrderETA(c *gin.Context) {
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
if timeSinceUpdate < 2*time.Minute {
if timeSinceUpdate < 30*time.Second {
// Cache valide
var etaMinutes int64
if etaStr, ok := etaData["eta_minutes"]; ok {
+86
View File
@@ -245,6 +245,92 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
})
}
// RegisterAdminPushToken enregistre le push token d'un admin
// POST /api/v2/admin/protected/push-token
func RegisterAdminPushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
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.SaveUserPushToken(username, req.PushToken); err != nil {
log.Printf("❌ [ADMIN_PUSH_TOKEN] Erreur sauvegarde pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [ADMIN_PUSH_TOKEN] Token enregistré pour admin %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterAdminPushToken supprime le push token d'un admin (au logout)
// DELETE /api/v2/admin/protected/push-token
func UnregisterAdminPushToken(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)
if err := database.DeleteUserPushToken(username); err != nil {
log.Printf("❌ [ADMIN_PUSH_TOKEN] Erreur suppression pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [ADMIN_PUSH_TOKEN] Token supprimé pour admin %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// RegisterCabinePushToken enregistre le push token d'un agent cabine
// POST /api/v1/cabine/push-token
func RegisterCabinePushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
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.SaveUserPushToken(username, req.PushToken); err != nil {
log.Printf("❌ [CABINE_PUSH_TOKEN] Erreur sauvegarde pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [CABINE_PUSH_TOKEN] Token enregistré pour cabine %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterCabinePushToken supprime le push token d'un agent cabine (au logout)
// DELETE /api/v1/cabine/push-token
func UnregisterCabinePushToken(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)
if err := database.DeleteUserPushToken(username); err != nil {
log.Printf("❌ [CABINE_PUSH_TOKEN] Erreur suppression pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [CABINE_PUSH_TOKEN] Token supprimé pour cabine %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MarkNotificationsRead marque toutes les notifications comme lues
// POST /api/v1/notifications/read
func MarkNotificationsRead(c *gin.Context) {
+70 -10
View File
@@ -319,7 +319,8 @@ func ValidateBasket(c *gin.Context) {
usernameStr := username.(string)
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
@@ -363,7 +364,16 @@ func ValidateBasket(c *gin.Context) {
}
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal)
// Récupérer les paramètres globaux (zones + parrainage)
appSettings, _ := database.GetSettings()
// Récupérer le solde parrainage disponible (seulement si le système est activé)
var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
if !zoneResult.OK {
if zoneResult.ZoneName == "inconnue" {
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
@@ -379,18 +389,41 @@ func ValidateBasket(c *gin.Context) {
} else {
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"referral_balance": referralBalance,
})
}
return
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount)
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance
if effectivePayment < zoneResult.MinAmount {
needed := zoneResult.MinAmount + referralBalance
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": needed,
"cart_total": cartTotal,
"missing": needed - cartTotal,
"referral_balance": referralBalance,
"postal_code": zoneResult.PostalCode,
})
return
}
referralUsed = referralBalance
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
// ============================================
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
@@ -404,6 +437,27 @@ func ValidateBasket(c *gin.Context) {
commandID := command.ID
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// Débiter le solde parrainage si utilisé
if referralUsed > 0 {
tx, txErr := database.Begin()
if txErr == nil {
if txErr = database.UseClientReferralBalance(tx, usernameStr, referralUsed); txErr != nil {
tx.Rollback()
log.Printf("⚠️ [CHECKOUT] Impossible de débiter le crédit parrainage: %v", txErr)
} else {
tx.Commit()
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
// Stocker le montant de parrainage sur la commande
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
}
}
}
}
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
// ============================================
// 3️⃣ Vider le panier
// ============================================
@@ -481,12 +535,15 @@ func ValidateBasket(c *gin.Context) {
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
// Notifier le client
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Un livreur est en route.", commandID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
assigned = true
@@ -510,11 +567,14 @@ func ValidateBasket(c *gin.Context) {
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{
"success": true,
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
"referral_used": referralUsed,
"referral_balance": newBalance,
}
if assigned {
+14 -19
View File
@@ -130,23 +130,18 @@ func validateUnit(unit string) error {
return nil
}
func validateCategory(category string) error {
// Nettoyage
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
validCategories := []string{"weed&hash", "zipette&co", "gros&semi"}
for _, v := range validCategories {
if category == v {
return nil
}
func validateCategory(database *db.Database, category string) error {
if category == "" {
return fmt.Errorf("catégorie requise")
}
return fmt.Errorf("catégorie invalide")
exists, err := database.CategoryExists(category)
if err != nil {
return fmt.Errorf("erreur vérification catégorie")
}
if !exists {
return fmt.Errorf("catégorie invalide")
}
return nil
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
@@ -246,7 +241,7 @@ func CreateProduct(c *gin.Context) {
return r
}, category)
if err := validateCategory(category); err != nil {
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -511,7 +506,7 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(category); err != nil {
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
@@ -627,7 +622,7 @@ func UpdateProduct(c *gin.Context) {
return
}
if err := validateCategory(updateData.Category); err != nil {
if err := validateCategory(database, updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
@@ -178,6 +179,9 @@ func UpdateLivreurLocation(c *gin.Context) {
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)",
usernameStr, req.Latitude, req.Longitude)
// ✅ Recalculer l'ETA en temps réel si livreur en_route
go refreshETAForActivDelivery(database, usernameStr, req.Latitude, req.Longitude)
// ✅ 2. Vérifier/Initialiser le statut du livreur
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
@@ -1049,3 +1053,89 @@ func GetRealtimeStats(c *gin.Context) {
"stats": stats,
})
}
// ============================================
// RECALCUL ETA EN TEMPS RÉEL (appelé à chaque update GPS)
// ============================================
// refreshETAForActivDelivery recalcule l'ETA depuis la position actuelle du livreur.
// Appelé en goroutine à chaque mise à jour GPS (toutes les ~15s).
func refreshETAForActivDelivery(database *db.Database, username string, lat, lon float64) {
// 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
if err != nil || statusData == "" {
return
}
var status map[string]interface{}
if err := json.Unmarshal([]byte(statusData), &status); err != nil {
return
}
// 2. Seulement si en_route ou arrived
currentStatus, _ := status["status"].(string)
if currentStatus != "en_route" && currentStatus != "arrived" {
return
}
// 3. Récupérer la commande active
var commandID int
switch v := status["current_command"].(type) {
case float64:
commandID = int(v)
case int:
commandID = v
default:
return
}
if commandID <= 0 {
return
}
// 4. Récupérer les coordonnées destination depuis le cache Redis
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err != nil || destData == "" {
return
}
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err != nil || coords.Lat == 0 {
return
}
// 5. Calculer l'ETA depuis la position GPS actuelle
from := services.Coordinates{Latitude: lat, Longitude: lon}
to := services.Coordinates{Latitude: coords.Lat, Longitude: coords.Lon}
etaMinutes, distanceKm, err := services.GetETAWithTraffic(from, to)
if err != nil {
// Fallback Haversine uniquement si TomTom indisponible
distanceKm = services.CalculateDistance(from, to)
etaMinutes = services.CalculateETA(distanceKm)
log.Printf("⚠️ [ETA_REALTIME] TomTom indisponible pour %s cmd %d, fallback: %.2fkm → %dmin",
username, commandID, distanceKm, etaMinutes)
} else {
log.Printf("🔄 [ETA_REALTIME] %s cmd %d recalculé: %.2fkm → %dmin (TomTom)",
username, commandID, distanceKm, etaMinutes)
}
// 6. Mettre à jour le cache Redis ETA (écrase l'ancien)
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaKey := fmt.Sprintf("command:eta:%d", commandID)
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
})
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
}
+79
View File
@@ -0,0 +1,79 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
func GetMyReferralBalance(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
settings, _ := database.GetSettings()
if !settings.ReferralEnabled {
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
return
}
balance, err := database.GetClientReferralBalance(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
}
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
func CreditClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Amount float64 `json:"amount" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
return
}
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
log.Printf("❌ [REFERRAL] Crédit échoué pour %s: %v", targetUsername, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage crédité",
"balance": balance,
})
}
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
func GetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
balance, err := database.GetClientReferralBalance(targetUsername)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"balance": balance,
})
}
+67
View File
@@ -0,0 +1,67 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GET /api/v1/app-settings — public, sans auth
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
func GetPublicSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
// En cas d'erreur, retourner les valeurs par défaut
settings = db.DefaultSettings()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"penalties_enabled": settings.PenaltiesEnabled,
"show_amende_score": settings.ShowAmendeScore,
"points_enabled": settings.PointsEnabled,
"points_separated": settings.PointsSeparated,
"referral_enabled": settings.ReferralEnabled,
"delivery_schedule": settings.DeliverySchedule,
})
}
// GET /api/v2/admin/protected/settings
func GetSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
}
// PUT /api/v2/admin/protected/settings
func UpdateSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req db.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
return
}
if err := database.UpdateSettings(req); err != nil {
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
return
}
log.Printf("✅ [SETTINGS] Mise à jour: penalties=%v, points_separated=%v, weed=%v, zipette=%v, total=%v",
req.PenaltiesEnabled, req.PointsSeparated, req.PointsCategoriesWeed, req.PointsCategoriesZipette, req.PointsCategoriesTotal)
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
}
-572
View File
@@ -6,565 +6,9 @@ package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// INCIDENTS TRAFFIC TOMTOM
// ============================================
// GetIncidentsAroundDeliveryPerson récupère les incidents autour d'un livreur
// GET /api/v2/admin/traffic/delivery/:username/incidents
func GetIncidentsAroundDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
// Récupérer position du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur non trouvée",
"details": err.Error(),
})
return
}
// Rayon de recherche par défaut: 5 km
radius := 5000 // mètres
// Récupérer incidents TomTom
incidents, err := fetchIncidents(lat, lon, radius)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération incidents",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"username": username,
"location": gin.H{"latitude": lat, "longitude": lon},
"radius_km": radius / 1000,
"incidents": incidents,
"count": len(incidents),
})
}
// GetIncidentsForAllDeliveries récupère incidents + routes pour tous livreurs actifs
// GET /api/v2/admin/traffic/incidents/all
func GetIncidentsForAllDeliveries(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer tous les livreurs disponibles depuis Redis
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération livreurs",
"details": err.Error(),
})
return
}
results := []gin.H{}
for _, livreur := range livreurs {
username := livreur.Username
status := livreur.Status
// Sauter les livreurs offline
if status == "offline" {
continue
}
// Position livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("⚠️ Position non trouvée pour %s", username)
continue
}
// Vérifier s'il a une commande en cours
commandID := livreur.CurrentCommand
if commandID == 0 {
// Pas de livraison en cours
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"has_delivery": false,
"incidents": []gin.H{},
"route": nil,
})
continue
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("⚠️ Commande %d non trouvée", commandID)
continue
}
// Coordonnées destination
var destLat, destLon float64
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
destLon = dLon
}
// Si pas de coordonnées, géocoder
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
continue
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("⚠️ Géocodage échoué pour %s", address)
continue
}
destLat = location.Latitude
destLon = location.Longitude
}
// Récupérer incidents sur le trajet
incidents, _ := fetchIncidentsOnRoute(lat, lon, destLat, destLon)
// Convertir incidents en gin.H pour JSON
incidentsJSON := make([]gin.H, len(incidents))
for i, inc := range incidents {
incidentsJSON[i] = gin.H{
"type": inc.Type,
"icon": inc.Icon,
"description": inc.Description,
}
}
// Calculer route avec trafic
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
log.Printf("⚠️ Erreur calcul route pour %s", username)
routeSummary = models.RouteSummary{}
}
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"destination": gin.H{"latitude": destLat, "longitude": destLon},
"has_delivery": true,
"command_id": commandID,
"incidents": incidentsJSON,
"route": routeSummary,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": results,
"count": len(results),
})
}
// ============================================
// MISE À JOUR ETA AVEC TRAFIC
// ============================================
func UpdateETAWithRealTraffic(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer l'ID de la commande
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
"command_id": commandID,
})
return
}
// Vérifier qu'un livreur est assigné
livreurAssign, ok := command["livreur_assign"].(string)
if !ok || livreurAssign == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Aucun livreur assigné",
"command_id": commandID,
})
return
}
// Position actuelle du livreur
lat, lon, err := database.GetDeliveryPersonLocation(livreurAssign)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur introuvable",
"livreur": livreurAssign,
"details": err.Error(),
})
return
}
var destLat, destLon float64
// 🔹 1. Tenter de récupérer depuis le cache Redis (clé spécifique pour destination)
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
destLat = coords.Lat
destLon = coords.Lon
log.Printf("📍 Destination trouvée dans cache Redis pour commande %d", commandID)
}
}
// 🔹 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, okLat := getFloatFromMap(command, "dest_latitude"); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := getFloatFromMap(command, "dest_longitude"); okLon && dLon != 0 {
destLon = dLon
}
}
// 🔹 3. Si toujours pas de coordonnées, géocoder l'adresse
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse de destination manquante ou invalide",
"command_id": commandID,
})
return
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de géocoder l'adresse",
"address": address,
"command_id": commandID,
"details": err.Error(),
})
return
}
destLat = location.Latitude
destLon = location.Longitude
log.Printf("📍 Adresse géocodée pour commande %d: %s -> (%.6f, %.6f)",
commandID, address, destLat, destLon)
}
// 🔹 4. Sauvegarder les coordonnées destination dans le cache Redis
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": destLat,
"lon": destLon,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder destination dans Redis: %v", err)
}
// 🔹 5. Calculer le temps réel avec TomTom Routing API
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de calculer l'itinéraire",
"command_id": commandID,
"from": gin.H{"lat": lat, "lon": lon},
"to": gin.H{"lat": destLat, "lon": destLon},
"details": err.Error(),
})
return
}
// 🔹 6. Mettre à jour l'ETA dans Redis
err = database.SetCommandETA(commandID, routeSummary.TravelTimeInMinutes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour ETA",
"command_id": commandID,
"details": err.Error(),
})
return
}
log.Printf("✅ ETA mis à jour pour commande %d: %d min (trafic réel inclus)",
commandID, routeSummary.TravelTimeInMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"eta_minutes": routeSummary.TravelTimeInMinutes,
"distance_km": routeSummary.LengthInKm,
"with_traffic": true,
"route_summary": routeSummary,
})
}
// ============================================
// FONCTIONS HELPERS - TOMTOM API
// ============================================
// fetchIncidents récupère les incidents de trafic autour d'une position
func fetchIncidents(lat, lon float64, radius int) ([]models.Incident, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// API TomTom Traffic Incidents
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
lon-0.05, lat-0.05, // Southwest corner
lon+0.05, lat+0.05, // Northeast corner
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("erreur requête incidents: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API incidents error %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("erreur lecture réponse: %w", err)
}
var incidentResponse models.IncidentResponse
err = json.Unmarshal(body, &incidentResponse)
if err != nil {
return nil, fmt.Errorf("erreur parsing incidents: %w", err)
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// fetchIncidentsOnRoute récupère les incidents sur un trajet
func fetchIncidentsOnRoute(startLat, startLon, destLat, destLon float64) ([]models.Incident, error) {
// Calculer la bounding box du trajet
minLat := min(startLat, destLat) - 0.02
maxLat := max(startLat, destLat) + 0.02
minLon := min(startLon, destLon) - 0.02
maxLon := max(startLon, destLon) + 0.02
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return []models.Incident{}, nil
}
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
minLon, minLat,
maxLon, maxLat,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return []models.Incident{}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return []models.Incident{}, nil
}
body, _ := io.ReadAll(resp.Body)
var incidentResponse models.IncidentResponse
if err := json.Unmarshal(body, &incidentResponse); err != nil {
return []models.Incident{}, nil
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// récupère le temps de trajet réel via l'API TomTom Routing
// fetchRouteSummary récupère le temps de trajet réel via l'API TomTom Routing
func fetchRouteSummary(startLat, startLon, destLat, destLon float64) (models.RouteSummary, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return models.RouteSummary{}, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// Validation des coordonnées
if startLat < -90 || startLat > 90 || destLat < -90 || destLat > 90 {
return models.RouteSummary{}, fmt.Errorf("latitude invalide: start=%.6f, dest=%.6f", startLat, destLat)
}
if startLon < -180 || startLon > 180 || destLon < -180 || destLon > 180 {
return models.RouteSummary{}, fmt.Errorf("longitude invalide: start=%.6f, dest=%.6f", startLon, destLon)
}
// API TomTom Routing: Calculate Route
url := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
startLat, startLon, destLat, destLon, apiKey,
)
log.Printf("🛣️ Appel TomTom: (%.6f,%.6f) -> (%.6f,%.6f)", startLat, startLon, destLat, destLon)
// Timeout réduit à 8 secondes
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url)
if err != nil {
// Fallback: estimation basée sur distance Haversine
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3 // ~25 km/h en ville + 3 min marge
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom timeout/erreur, fallback: %.2f km -> %d min estimé", distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil // Pas d'erreur, on retourne l'estimation
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.ReadAll(resp.Body) // Lire et ignorer le body pour fermer proprement
// Fallback en cas d'erreur API
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min", resp.StatusCode, distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur lecture réponse: %w", err)
}
var routeResponse models.RouteResponse
err = json.Unmarshal(body, &routeResponse)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur parsing routing: %w", err)
}
if len(routeResponse.Routes) == 0 {
return models.RouteSummary{}, fmt.Errorf("aucun itinéraire trouvé")
}
summary := routeResponse.Routes[0].Summary
summary.TravelTimeInMinutes = (summary.TravelTimeInSeconds + 59) / 60
summary.LengthInKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ Route calculée: %.2f km, %d min (trafic inclus)", summary.LengthInKm, summary.TravelTimeInMinutes)
return summary, nil
}
// haversineDistance calcule la distance en km entre deux points GPS
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0 // Rayon Terre en km
const toRad = math.Pi / 180.0
dLat := (lat2 - lat1) * toRad
dLon := (lon2 - lon1) * toRad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*toRad)*math.Cos(lat2*toRad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
// getFloatFromMap récupère un float64 depuis une map avec différents types
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
value, exists := m[key]
@@ -606,19 +50,3 @@ func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
return 0, false
}
}
// min retourne le minimum entre deux float64
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}
// max retourne le maximum entre deux float64
func max(a, b float64) float64 {
if a > b {
return a
}
return b
}
@@ -1,11 +1,3 @@
// ============================================
// handlers/delivery_validation_handler.go - CLEAN VERSION
// VALIDATION LIVRAISON AVEC VÉRIFICATION PROXIMITÉ GPS
//
// ⚠️ IMPORTANT: Ce fichier contient UNIQUEMENT les fonctions livreur
// Les fonctions ADMIN sont dans cabine_handlers.go
// ============================================
package handlers
import (
@@ -358,11 +350,11 @@ func StartDelivery(c *gin.Context) {
// Vérifier le statut actuel
currentStatus, _ := command["status"].(string)
if currentStatus != "support" && currentStatus != "assigned" {
if currentStatus != "assigned" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de démarrer cette livraison",
"current_status": currentStatus,
"message": "La commande doit être en statut 'support' ou 'assigned'",
"message": "La commande doit être en statut 'assigned'",
})
return
}
+11 -76
View File
@@ -1,66 +1,9 @@
package handlers
import "regexp"
// ============================================================
// Zones de livraison — minimum de commande par code postal
// ============================================================
// Remplis les listes de codes postaux quand tu les as.
// Un code postal absent de toutes les zones → commande refusée.
// ============================================================
type deliveryZone struct {
Name string
MinAmount float64
codes map[string]struct{}
}
var deliveryZones = []deliveryZone{
{
Name: "Zone 30€",
MinAmount: 30.0,
codes: postalSet([]string{
"44000",
"44100",
"44200",
"44300",
}),
},
{
Name: "Zone 50€",
MinAmount: 50.0,
codes: postalSet([]string{
"44400", // Rezé
"44880", // Les Sorinières / Sautron
"44120", // Vertou
"44230", // Saint-Sébastien-sur-Loire
"44115", // Basse-Goulaine / Haute-Goulaine
"44980", // Sainte-Luce-sur-Loire
"44470", // Carquefou
"44240", // La Chapelle-sur-Erdre
"44700", // Orvault
"44800", // Saint-Herblain
"44340", // Bouguenais
"44620", // La Montagne
"44830", // Bouaye
}),
},
{
Name: "Zone 100€",
MinAmount: 100.0,
codes: postalSet([]string{
"44860", // Pont-Saint-Martin / Saint-Aignan-Grandlieu
"44220", // Couëron
"44118", // La Chevrolière
"44830", // Brains
"44710", // Saint-Léger-les-Vignes
"44690", // La Haie-Fouassière
"44470", // Mauves-sur-Loire
"44240", // Sucé-sur-Erdre
"44119", // Grandchamp-des-Fontaines
}),
},
}
import (
"gestion/db"
"regexp"
)
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
@@ -91,21 +34,18 @@ type zoneCheckResult struct {
}
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
// Les zones sont lues depuis la DB (settings.PostalZones).
// Code postal introuvable → OK = false (refus).
// Code postal hors de toutes les zones → OK = false (refus).
func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
func checkDeliveryZone(deliveryAddress string, total float64, zones []db.PostalZone) zoneCheckResult {
code := extractPostalCode(deliveryAddress)
if code == "" {
return zoneCheckResult{
PostalCode: "",
ZoneName: "inconnue",
MinAmount: 0,
OK: false,
}
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
}
for _, zone := range deliveryZones {
if _, found := zone.codes[code]; found {
for _, zone := range zones {
set := postalSet(zone.Codes)
if _, found := set[code]; found {
return zoneCheckResult{
PostalCode: code,
ZoneName: zone.Name,
@@ -115,10 +55,5 @@ func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
}
}
return zoneCheckResult{
PostalCode: code,
ZoneName: "hors zone",
MinAmount: 0,
OK: false,
}
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
}
@@ -0,0 +1,83 @@
package middleware
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// BlockClientIfPenalty bloque le checkout si le client a une amende non payée.
// Lit d'abord les paramètres globaux (penalties_enabled), puis le PenaltyCache Redis, fallback DB.
func BlockClientIfPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Vérifier si les amendes sont activées dans les paramètres globaux
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
c.Next()
return
}
clientID, exists := c.Get("client_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
// 1. Tenter le cache Redis via la session
if id, ok := clientID.(int); ok {
if session, err := database.GetClientSession(id); err == nil {
if session.PenaltyCache > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": session.PenaltyCache,
"blocked": true,
})
c.Abort()
return
}
// Cache présent et amende = 0 → on laisse passer sans requête DB
c.Next()
return
}
}
// 2. Fallback DB si session Redis absente/expirée
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
usernameStr, ok := username.(string)
if !ok || usernameStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
c.Abort()
return
}
amende, err := database.GetClientAmende(usernameStr)
if err != nil {
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
// En cas d'erreur DB on laisse passer pour ne pas bloquer l'utilisateur injustement
c.Next()
return
}
if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": amende,
"blocked": true,
})
c.Abort()
return
}
c.Next()
}
+54 -6
View File
@@ -1,6 +1,8 @@
package middleware
import (
"fmt"
"gestion/db"
"log"
"net/http"
"time"
@@ -14,21 +16,67 @@ func OrderHoursMiddleware(c *gin.Context) {
loc = time.UTC
}
now := time.Now().In(loc)
weekday := now.Weekday()
hour := now.Hour()
min := now.Minute()
// Récupérer le planning depuis les settings DB
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
c.Next()
return
}
sched := settings.DeliverySchedule
var day db.DaySchedule
switch weekday {
case time.Monday:
day = sched.Monday
case time.Tuesday:
day = sched.Tuesday
case time.Wednesday:
day = sched.Wednesday
case time.Thursday:
day = sched.Thursday
case time.Friday:
day = sched.Friday
case time.Saturday:
day = sched.Saturday
case time.Sunday:
day = sched.Sunday
}
if !day.Enabled {
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles aujourd'hui",
"message": "La livraison n'est pas disponible ce jour",
"current_time": now.Format("15:04"),
})
c.Abort()
return
}
parseTime := func(t string) int {
var h, m int
fmt.Sscanf(t, "%d:%d", &h, &m)
return h*60 + m
}
currentMinutes := hour*60 + min
openMinutes := 13*60 + 55
closeMinutes := 23*60 + 30
openMinutes := parseTime(day.OpenTime)
closeMinutes := parseTime(day.CloseTime)
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: 13h55 - 23h30)", hour, min)
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles à cette heure",
"message": "Vous pouvez commander entre 13h55 et 23h30",
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
"current_time": now.Format("15:04"),
"open_at": "13:55",
"close_at": "23:30",
"open_at": day.OpenTime,
"close_at": day.CloseTime,
})
c.Abort()
return
+1
View File
@@ -4,6 +4,7 @@ type AlertPolicy struct {
ID int `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+2 -1
View File
@@ -14,9 +14,10 @@ type Client struct {
Point int `json:"point"`
PointZipette int `json:"points_zipette"`
Amende float64 `json:"amende"`
CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU
CancellationsCount int `json:"cancellations_count"`
LastPenaltyReason string `json:"last_penalty_reason"`
MustChangePassword bool `json:"must_change_password"`
PushToken string `json:"-"`
ReferralBalance float64 `json:"referral_balance"`
CreatedAt time.Time `json:"created_at"`
}
+46 -181
View File
@@ -45,13 +45,15 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
}
// ============================================
// 📦 PRODUITS (v1) - PUBLIC (SANS middleware!)
// 📦 PRODUITS & CATÉGORIES (v1) - PUBLIC (SANS middleware!)
// ============================================
productsGroupV1 := router.Group("/api/v1")
{
productsGroupV1.GET("/products", handlers.GetAllProducts)
productsGroupV1.GET("/products/:id", handlers.GetProductByID)
productsGroupV1.GET("/products/category/:category", handlers.GetProductsByCategory)
productsGroupV1.GET("/categories", handlers.GetCategories)
productsGroupV1.GET("/app-settings", handlers.GetPublicSettings)
}
// ============================================
@@ -68,8 +70,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cartGroupV1.DELETE("/panier/clear", handlers.ClearBasket) // ✅ CORRIGÉ - Sans :username
// Commandes
cartGroupV1.POST("/checkout", middleware.OrderHoursMiddleware, handlers.ValidateBasket) // ✅ Auto-assign GPS
cartGroupV1.GET("/my-commands", handlers.GetMyCommandsWithTracking) // ✅ Avec suivi
cartGroupV1.POST("/checkout", middleware.OrderHoursMiddleware, middleware.BlockClientIfPenalty, handlers.ValidateBasket) // ✅ Auto-assign GPS
cartGroupV1.GET("/my-commands", handlers.GetMyCommandsWithTracking) // ✅ Avec suivi
// ⭐ NOUVEAUX - SUIVI CLIENT TEMPS RÉEL
cartGroupV1.GET("/commands/:id/eta", handlers.GetOrderETA) // ✅ AJOUTÉ
@@ -102,6 +104,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
// 🎁 PARRAINAGE CLIENT
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
}
// ============================================
@@ -133,6 +138,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2 := router.Group("/api/v2/admin/protected")
adminGroupV2.Use(middleware.AdminMiddleware)
{
// PUSH TOKEN ADMIN
adminGroupV2.POST("/push-token", handlers.RegisterAdminPushToken)
adminGroupV2.DELETE("/push-token", handlers.UnregisterAdminPushToken)
// 🔔 NOTIFICATIONS ADMIN
adminGroupV2.GET("/notifications", handlers.GetLivreurNotifications)
adminGroupV2.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
// ============================================
// CLIENT - GESTION
// ============================================
@@ -163,6 +176,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
// ============================================
// CATÉGORIES - GESTION ADMIN
// ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
// ============================================
// COMMANDES - GESTION DE BASE
// ============================================
adminGroupV2.GET("/orders", handlers.GetAllCommands)
@@ -225,6 +244,16 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
// ============================================
// ⚙️ PARAMÈTRES GLOBAUX
// ============================================
adminGroupV2.GET("/settings", handlers.GetSettings)
adminGroupV2.PUT("/settings", handlers.UpdateSettings)
// 🎁 PARRAINAGE ADMIN
adminGroupV2.GET("/client/:username/referral", handlers.GetClientReferralAdmin)
adminGroupV2.POST("/client/:username/referral/credit", handlers.CreditClientReferralAdmin)
// ============================================
// ⭐⭐ ALERTES POLICE - GESTION ADMIN
// ============================================
@@ -241,6 +270,20 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cabineGroupV1 := router.Group("/api/v1/cabine")
cabineGroupV1.Use(middleware.CabineMiddleware)
{
// ============================================
// ADDRESSES - GESTION
// ============================================
cabineGroupV1.POST("/add/address", handlers.AddAddress)
cabineGroupV1.DELETE("/delete/address", handlers.DeleteAddress)
cabineGroupV1.GET("/addresses", handlers.GetAllAddress)
// PUSH TOKEN CABINE
cabineGroupV1.POST("/push-token", handlers.RegisterCabinePushToken)
cabineGroupV1.DELETE("/push-token", handlers.UnregisterCabinePushToken)
// 🔔 NOTIFICATIONS CABINE
cabineGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
cabineGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
@@ -314,181 +357,3 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
livreurGroupV1.DELETE("/push-token", handlers.UnregisterLivreurPushToken)
}
}
// ============================================
// 📝 DOCUMENTATION COMPLÈTE
// ============================================
/*
LISTE COMPLÈTE DES ROUTES:
CLIENT API (v1) - /api/v1
📌 AUTH (PUBLIC)
POST /api/v1/auth/register Créer compte client
POST /api/v1/auth/login Login client
POST /api/v1/auth/logout Logout client
📌 PRODUITS (PUBLIC)
GET /api/v1/products Tous les produits
GET /api/v1/products/:id Un produit
GET /api/v1/products/category/:cat Par catégorie
GET /api/v1/health Health check
📌 GÉOCODAGE (PUBLIC)
POST /api/v1/geocode Convertir adresse en GPS
POST /api/v1/validate-address Valider une adresse
📌 PANIER (AUTH CLIENT) 🔒
POST /api/v1/panier/add Ajouter au panier
GET /api/v1/panier/:username Voir panier
DELETE /api/v1/panier/remove Supprimer du panier
DELETE /api/v1/panier/clear Vider panier
📌 COMMANDES (AUTH CLIENT) 🔒
POST /api/v1/checkout Valider panier AUTO-ASSIGN GPS
GET /api/v1/my-commands Mes commandes avec suivi
GET /api/v1/commands/:id/eta NOUVEAU - ETA de la commande
GET /api/v1/commands/:id/status NOUVEAU - Statut temps réel
GET /api/v1/commands/:id/tracking NOUVEAU - Timeline détaillée
POST /api/v1/commands/:id/approve Approuver livraison
POST /api/v1/commands/:id/cancel NOUVEAU - Annuler commande
GET /api/v1/my-cancellation-history NOUVEAU - Historique annulations
GET /api/v1/my-commands/history NOUVEAU - Historique commandes
GET /api/v1/my-commands/history/detailed NOUVEAU - Historique détaillé
GET /api/v1/commands/:id/history NOUVEAU - Historique d'une commande
📌 PÉNALITÉS (AUTH CLIENT) 🔒
GET /api/v1/penalties NOUVEAU - Voir mes pénalités
📌 PROFIL (AUTH CLIENT) 🔒
PUT /api/v1/profile/update NOUVEAU - Modifier mon profil
ADMIN API (v2) - /api/v2/admin
📌 AUTH (PUBLIC)
POST /api/v2/admin/auth/register Créer compte admin
POST /api/v2/admin/auth/login Login admin
POST /api/v2/admin/auth/logout Logout admin
📌 GESTION UTILISATEURS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/all/clients Liste tous les clients
GET /api/v2/admin/protected/all/users Liste tous les users
PUT /api/v2/admin/protected/clients/:id NOUVEAU - Modifier un client
PUT /api/v2/admin/protected/users/:id NOUVEAU - Modifier un user
📌 PRODUITS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/products Tous produits
POST /api/v2/admin/protected/products Créer produit
GET /api/v2/admin/protected/products/:id Détail produit
PUT /api/v2/admin/protected/products/:id Modifier produit
DELETE /api/v2/admin/protected/products/:id Supprimer produit
📌 COMMANDES (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/orders Toutes commandes
GET /api/v2/admin/protected/orders/:id Détail commande
PUT /api/v2/admin/protected/orders/:id/address Modifier adresse
POST /api/v2/admin/protected/orders/:id/force-validate Forcer validation
GET /api/v2/admin/protected/orders/cancelled Commandes annulées
GET /api/v2/admin/protected/commands/:id/deliveryman/location Position livreur pour commande
📌 AUTO-ASSIGNATION GPS (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/orders/:id/auto-assign Assigner 1 commande
POST /api/v2/admin/protected/commands/auto-assign-all Assigner toutes
📌 RECHERCHE LIVREUR (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/delivery/nearest Livreur le plus proche
POST /api/v2/admin/protected/delivery/distances Tous livreurs + distances
📌 GESTION QUEUES (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery/queues Toutes les queues
GET /api/v2/admin/protected/delivery/:username/queue Queue d'un livreur
📌 VISUALISATION (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery/heatmap Heatmap livreurs
📌 GÉOCODAGE (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/geocode Convertir adresse
POST /api/v2/admin/protected/validate-address Valider adresse
📌 GESTION LIVREURS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery-persons Liste livreurs
GET /api/v2/admin/protected/delivery-persons/:username Détails d'un livreur
GET /api/v2/admin/protected/delivery-persons/:username/stats Statistiques livreur
GET /api/v2/admin/protected/delivery-persons/:username/history Historique livreur
GET /api/v2/admin/protected/delivery-persons/:username/location NOUVEAU - Position GPS livreur
PUT /api/v2/admin/protected/delivery-persons/:username/location Modifier position livreur
PUT /api/v2/admin/protected/delivery-persons/:username/status Modifier statut livreur
POST /api/v2/admin/protected/delivery-persons/:username/assign/:command_id Assigner manuellement
DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id Retirer de la queue
GET /api/v2/admin/protected/delivery-persons/:username/map-links Liens carte
📌 PÉNALITÉS (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/penalty Appliquer pénalité
GET /api/v2/admin/protected/client/:username/penalties Voir pénalités client
POST /api/v2/admin/protected/client/:username/penalties/reset Reset pénalités
GET /api/v2/admin/protected/penalties/all Tous clients avec pénalités
GET /api/v2/admin/protected/penalties/stats Stats pénalités
CABINE API (v1) - /api/v1/cabine
📌 GESTION ITEMS (AUTH CABINE) 🔒
GET /api/v1/cabine/commands/:id/items Voir items d'une commande
PUT /api/v1/cabine/items/:item_id/status Changer statut item
GET /api/v1/cabine/commands/cancelled Commandes annulées
GET /api/v1/cabine/commands/:id/deliveryman/location Position livreur pour commande
📌 PÉNALITÉS (AUTH CABINE) 🔒
POST /api/v1/cabine/penalty Appliquer pénalité
GET /api/v1/cabine/client/:username/penalties Voir pénalités client
POST /api/v1/cabine/client/:username/penalties/reset Reset pénalités
GET /api/v1/cabine/penalties/all Tous clients avec pénalités
GET /api/v1/cabine/penalties/stats Stats pénalités
LIVREUR API (v1) - /api/v1/livreur
📌 LIVRAISONS (AUTH LIVREUR) 🔒
GET /api/v1/livreur/deliveries Mes livraisons (DONNÉES FILTRÉES)
GET /api/v1/livreur/deliveries/:id NOUVEAU - Détail livraison
POST /api/v1/livreur/deliveries/:id/start Démarrer livraison
PUT /api/v1/livreur/deliveries/:id/status Changer statut (AVEC GPS)
📌 POSITION GPS (AUTH LIVREUR) 🔒
POST /api/v1/livreur/location/update Mettre à jour ma position
GET /api/v1/livreur/location Voir ma position actuelle
📌 STATUT (AUTH LIVREUR) 🔒
POST /api/v1/livreur/status Changer mon statut
GET /api/v1/livreur/status Voir mon statut
📌 QUEUE (AUTH LIVREUR) 🔒
GET /api/v1/livreur/queue Voir ma queue de livraisons
*/
// ============================================
// 🔧 CORRECTIONS APPLIQUÉES
// ============================================
/*
1. Route ETA ajoutée: GET /api/v1/commands/:id/eta
2. Route tracking détaillée: GET /api/v1/commands/:id/tracking
3. Route status temps réel: GET /api/v1/commands/:id/status
4. Panier clear sans :username (utilise JWT)
5. GeoService injecté dans le contexte global
6. Routes livreur pour GPS et statut
7. Routes admin pour gestion manuelle livreurs
8. Routes modification profil CLIENT par le client: PUT /api/v1/profile/update
9. Routes modification profil CLIENT par admin: PUT /api/v2/admin/protected/clients/:id
10. Routes modification profil USER par admin: PUT /api/v2/admin/protected/users/:id
11. Route position GPS livreur par admin: GET /api/v2/admin/protected/delivery-persons/:username/location
*/
+10 -5
View File
@@ -184,16 +184,21 @@ func tryAssignCommandWithPriority(
// 8. Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
var clientUsername string
if cmd, err := database.GetCommandByID(commandID); err == nil {
if ru, ok := cmd["referral_used"].(float64); ok && ru > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", ru)
}
clientUsername, _ = cmd["username"].(string)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CRON] Erreur notification livreur %s: %v", nearest.Username, notifErr)
}
// 9. Notifier le client
if cmd, err := database.GetCommandByID(commandID); err == nil {
if clientUsername, ok := cmd["username"].(string); ok && clientUsername != "" {
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
database.NotifyClient(clientUsername, commandID, "assigned", clientMsg)
}
if clientUsername != "" {
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Un livreur est en route.", commandID)
database.NotifyClient(clientUsername, commandID, "assigned", clientMsg)
}
log.Printf("✅ [CRON] Cmd %d → %s (%.2f km) | Priorité #%d | Attente: %d min",
-149
View File
@@ -5,30 +5,20 @@
package workers
import (
"encoding/json"
"gestion/db"
"log"
"time"
)
// ============================================
// WORKER PRINCIPAL
// ============================================
// StartRedisWorkers démarre tous les workers Redis
func StartRedisWorkers(database *db.Database) {
log.Println("🚀 Démarrage des workers Redis...")
// Worker pour les notifications programmées
go NotificationWorker(database)
// Worker pour l'auto-assignation des commandes
go AutoAssignWorker(database)
// Worker pour le nettoyage des réservations expirées
go StockCleanupWorker(database)
// Worker pour la synchronisation des points
go PointsSyncWorker(database)
log.Println("✅ Tous les workers Redis sont démarrés")
@@ -174,142 +164,3 @@ func PointsSyncWorker(database *db.Database) {
}
}
}
// ============================================
// WORKER MISE À JOUR ETA
// ============================================
// ETAUpdateWorker met à jour automatiquement les ETAs
func ETAUpdateWorker(database *db.Database) {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
log.Println("⏱️ Worker Mise à Jour ETA démarré (check toutes les 2 min)")
for range ticker.C {
// Récupérer toutes les commandes en cours de livraison
commands, err := database.GetAllCommands("support", "")
if err != nil {
continue
}
for _, command := range commands {
commandID := command["id"].(int)
// Recalculer l'ETA basé sur la position du livreur
// (logique à implémenter selon vos besoins)
// Exemple: réduire l'ETA de 2 minutes
// database.SetCommandETA(commandID, newETA)
log.Printf("🔄 ETA mis à jour pour commande %d", commandID)
}
}
}
// ============================================
// WORKER ALERTES RETARD
// ============================================
// DelayAlertWorker envoie des alertes en cas de retard
func DelayAlertWorker(database *db.Database) {
ticker := time.NewTicker(3 * time.Minute)
defer ticker.Stop()
log.Println("⚠️ Worker Alertes Retard démarré (check toutes les 3 min)")
for range ticker.C {
// Récupérer les ETAs de toutes les commandes
keys, err := db.Redis.Keys(db.RedisCtx, "command:eta:*").Result()
if err != nil {
continue
}
for _, key := range keys {
data, err := db.Redis.Get(db.RedisCtx, key).Result()
if err != nil {
continue
}
// Désérialiser le JSON dans eta
var eta map[string]interface{}
if err := json.Unmarshal([]byte(data), &eta); err != nil {
log.Printf("⚠️ Impossible de parser ETA pour %s: %v", key, err)
continue
}
// Extraire l'heure d'arrivée
arrivalTimeFloat, ok := eta["arrival_time"].(float64)
if !ok {
continue
}
arrivalTime := int64(arrivalTimeFloat)
// Vérifier retard
now := time.Now().Unix()
if now > arrivalTime {
commandIDFloat, ok := eta["command_id"].(float64)
if !ok {
continue
}
commandID := int(commandIDFloat)
delay := (now - arrivalTime) / 60 // en minutes
log.Printf("⚠️ ALERTE: Commande %d en retard de %d minutes", commandID, delay)
// Notifier l'admin
database.PublishCommandEvent(commandID, "delay_alert", "Commande en retard")
if delay > 15 {
command, _ := database.GetCommandByID(commandID)
if livreur, ok := command["livreur_assign"].(string); ok {
log.Printf("⚠️ Pénalité appliquée au livreur %s", livreur)
}
}
}
}
}
}
// ============================================
// WORKER STATISTIQUES TEMPS RÉEL
// ============================================
// StatsWorker calcule des statistiques en temps réel
func StatsWorker(database *db.Database) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Println("📊 Worker Statistiques démarré (check toutes les 5 min)")
for range ticker.C {
// Nombre de commandes en attente
queueSize, _ := db.Redis.ZCard(db.RedisCtx, "queue:pending:sorted").Result()
// Nombre de livreurs disponibles
livreurs, _ := database.GetAvailableDeliveryPersonsRedis()
availableCount := len(livreurs)
// Nombre de livraisons en cours
commands, _ := database.GetAllCommands("support", "")
inProgressCount := len(commands)
stats := map[string]interface{}{
"queue_size": queueSize,
"available_drivers": availableCount,
"in_progress": inProgressCount,
"timestamp": time.Now().Unix(),
}
// Stocker dans Redis
db.Redis.HSet(db.RedisCtx, "stats:realtime",
"queue_size", stats["queue_size"],
"available_drivers", stats["available_drivers"],
"in_progress", stats["in_progress"],
"timestamp", stats["timestamp"],
)
log.Printf("📊 Stats: %d en attente | %d livreurs dispo | %d en cours",
queueSize, availableCount, inProgressCount)
}
}
+5
View File
@@ -12,6 +12,7 @@ import ProductDetail from "./pages/User/ProductDetail";
import Cart from "./pages/User/Cart";
import Checkout from "./pages/User/Checkout";
import OrderDetails from "./pages/User/OrderDetails";
import Parrainage from "./pages/User/Parrainage";
// Pages Login
import LoginClient from "./pages/LoginClient/Login";
@@ -71,6 +72,10 @@ function App() {
path="/user/commande/:orderId"
element={<OrderDetails />}
/>
<Route
path="/user/parrainage"
element={<Parrainage />}
/>
</Routes>
</CartProvider>
}
+73 -4
View File
@@ -5,8 +5,8 @@
// ✅ loginUser et registerUser retournent AuthResponse
// ✅ sessionStorage (pas localStorage)
const API_URL = "https://uber-stup.club/api/v1";
const BACKEND_URL = "https://uber-stup.club";
const API_URL = "http://5.181.0.112/api/v1";
const BACKEND_URL = "http://5.181.0.112";
export function getMediaUrl(url: string): string {
if (!url) return "";
@@ -683,7 +683,10 @@ export const createCheckout = async (checkoutData: CheckoutData) => {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(checkoutData),
body: JSON.stringify({
...checkoutData,
use_referral_balance: checkoutData.use_referral_balance ?? false,
}),
});
const data = await response.json();
@@ -783,6 +786,24 @@ export interface Product {
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
}
export interface Category {
id: number;
name: string;
color: string;
is_coming_soon: boolean;
created_at: string;
}
export const getCategories = async (): Promise<Category[]> => {
try {
const response = await fetch(`${API_URL}/categories`);
const data = await response.json();
return data.categories || [];
} catch {
return [];
}
};
export const getAllProducts = async () => {
try {
const response = await fetch(`${API_URL}/products`);
@@ -968,8 +989,9 @@ export const getOrderETA = async (commandId: number): Promise<ETAResponse> => {
success: true,
command_id: data.id || commandId,
eta_minutes: data.eta_minutes || 0,
estimated_arrival: data.estimated_arrival || "N/A",
estimated_arrival: data.estimated_arrival || "",
status: data.status || "pending",
eta_available: data.eta_available === true,
livreur_distance: data.livreur_distance,
message: "ETA récupéré",
};
@@ -1805,3 +1827,50 @@ export const markNotificationsRead = async (): Promise<{
return { success: false };
}
};
export interface PublicSettings {
penalties_enabled: boolean;
show_amende_score: boolean;
points_enabled: boolean;
points_separated: boolean;
referral_enabled: boolean;
}
export const getPublicSettings = async (): Promise<PublicSettings> => {
const defaults: PublicSettings = { penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true };
try {
const response = await fetch(`${API_URL}/app-settings`);
if (!response.ok) return defaults;
const data = await response.json();
return {
penalties_enabled: data.penalties_enabled ?? true,
show_amende_score: data.show_amende_score ?? true,
points_enabled: data.points_enabled ?? true,
points_separated: data.points_separated ?? true,
referral_enabled: data.referral_enabled ?? true,
};
} catch {
return defaults;
}
};
export interface ReferralBalanceResponse {
success: boolean;
balance: number;
referral_enabled?: boolean;
}
export const getReferralBalance = async (): Promise<ReferralBalanceResponse> => {
const token = getAuthToken();
if (!token) return { success: false, balance: 0 };
try {
const response = await fetch(`${API_URL}/referral/balance`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return { success: false, balance: 0 };
const data = await response.json();
return { success: true, balance: data.balance ?? 0, referral_enabled: data.referral_enabled };
} catch {
return { success: false, balance: 0 };
}
};
+6 -1
View File
@@ -202,6 +202,7 @@ export interface CheckoutData {
last_name?: string;
phone?: string;
payment_method?: string;
use_referral_balance?: boolean;
}
/**
@@ -598,6 +599,7 @@ export interface ETAResponse {
eta_minutes: number;
estimated_arrival: string;
status: string;
eta_available?: boolean;
livreur_distance?: number;
message?: string;
[key: string]: any;
@@ -731,6 +733,8 @@ export interface CheckoutCartResponse {
message: string;
command_id?: number;
delivery_address?: string;
referral_used?: number;
referral_balance?: number;
command?: {
id: number;
status: string;
@@ -754,8 +758,9 @@ export interface ConfirmReceptionResponse {
success: boolean;
message: string;
points_earned?: number;
category?: string; // 'total', 'zipette&co', 'weed&hash', 'mixed'
data?: {
category?: string; // ✅ Catégorie de points ('zipette&co', 'weed&hash', etc.)
category?: string;
points_earned?: number;
[key: string]: any;
};
+2
View File
@@ -13,6 +13,7 @@ import {
faBars,
faTimes,
faBell,
faGift,
} from "@fortawesome/free-solid-svg-icons";
import { faTelegram } from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
@@ -91,6 +92,7 @@ function Navbar() {
{ id: "panier", label: "Mon Panier", icon: faShoppingCart, path: "/user/panier" },
{ id: "suivi", label: "Suivi Livraison", icon: faTruck, path: "/user/suivi-livraison" },
{ id: "historique", label: "Historique", icon: faClockRotateLeft, path: "/user/consultation-historique" },
{ id: "parrainage", label: "Parrainage", icon: faGift, path: "/user/parrainage" },
];
const toggleMenu = () => setIsMenuOpen((v) => !v);
@@ -14,6 +14,7 @@ interface ProductCardProps {
prices?: Array<{ quantity: number; price: number }>;
hasVideo?: boolean;
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
categoryColor?: string;
}
function ProductCard({
@@ -27,6 +28,7 @@ function ProductCard({
prices,
hasVideo = false,
videoUrl,
categoryColor,
}: ProductCardProps) {
const navigate = useNavigate();
const { addToCart } = useCart();
@@ -162,6 +164,11 @@ function ProductCard({
className={`quick-add-btn ${isOutOfStock ? "disabled" : ""}`}
onClick={handleQuickAddClick}
disabled={isOutOfStock}
style={
categoryColor && !isOutOfStock
? { background: categoryColor }
: undefined
}
>
{isOutOfStock
? "Rupture de stock"
@@ -174,6 +181,11 @@ function ProductCard({
value={selectedQuantity ?? ""}
onChange={handleQuantitySelect}
onClick={(e) => e.stopPropagation()}
style={
categoryColor
? { borderColor: categoryColor }
: undefined
}
>
<option value="">Choisir une quantité</option>
{prices.map((priceOption) => (
+87 -68
View File
@@ -1,33 +1,30 @@
import { useState, useEffect } from "react";
import ProductCard from "../../components/ProductCard";
import Navbar from "../../components/Navbar";
import { getAllProducts, getProductsByCategory, getMediaUrl } from "../../api/api";
import type { Product } from "../../api/api";
import { getAllProducts, getProductsByCategory, getMediaUrl, getCategories } from "../../api/api";
import type { Product, Category } from "../../api/api";
import "./UserAccueil.css";
function UserAccueil() {
const [selectedCategory, setSelectedCategory] = useState<string>("tous");
const [categories, setCategories] = useState<Category[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const categories = [
{ label: "Tous", value: "tous" },
{ label: "Weed&Hash", value: "weed&hash" },
{ label: "Zipette&Co", value: "zipette&co" },
{ label: "Gros&Semi", value: "gros&semi" },
];
const categoryTitles: Record<string, string> = {
tous: "Tous les produits",
"weed&hash": "Weed & Hash",
"zipette&co": "Zipette & Co",
"gros&semi": "Gros & Semi",
};
useEffect(() => {
getCategories().then(setCategories);
}, []);
useEffect(() => {
const catObj = categories.find((c) => c.name === selectedCategory);
if (catObj?.is_coming_soon) {
setLoading(false);
setProducts([]);
return;
}
loadProducts();
}, [selectedCategory]);
}, [selectedCategory, categories]);
const loadProducts = async () => {
setLoading(true);
@@ -107,73 +104,95 @@ function UserAccueil() {
return "https://via.placeholder.com/400x400/1a1a1a/ffffff?text=No+Image";
};
const grosSemiStyle =
selectedCategory === "gros&semi"
? {
backgroundImage: `url('/logo-gros-semi.png')`,
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
backgroundSize: "contain",
backgroundAttachment: "local",
}
: {};
const selectedCategoryObj = categories.find((c) => c.name === selectedCategory);
const isSelectedComingSoon = selectedCategoryObj?.is_coming_soon ?? false;
return (
<>
<Navbar />
<div className="user-page-container" style={grosSemiStyle}>
<div className="user-page-container">
<div className="category-filter">
{categories.map((category) => (
<button
key={category.value}
className={`category-button ${selectedCategory === category.value ? "active" : ""}`}
data-category={category.value}
onClick={() => handleCategoryChange(category.value)}
>
{category.label}
</button>
))}
<button
className={`category-button ${selectedCategory === "tous" ? "active" : ""}`}
data-category="tous"
onClick={() => handleCategoryChange("tous")}
>
Tous
</button>
{categories.map((category) => {
const isActive = selectedCategory === category.name;
const catColor = category.color || "#7c3aed";
return (
<button
key={category.id}
className={`category-button ${isActive ? "active" : ""}`}
style={
isActive
? {
backgroundColor: catColor,
borderColor: catColor,
color: "#ffffff",
}
: { borderColor: `${catColor}66` }
}
onClick={() => handleCategoryChange(category.name)}
>
{category.name}
</button>
);
})}
</div>
<div className="category-header">
<h2 className="category-title">
{categoryTitles[selectedCategory]}
{selectedCategory === "tous" ? "Tous les produits" : selectedCategory}
</h2>
</div>
{loading && (
<div className="loading-container">
<p>Chargement des produits...</p>
</div>
)}
{!loading && !error && products.length > 0 && (
<div className="products-grid">
{products.map((product) => (
<div
key={product.id}
data-category={product.category}
>
<ProductCard
id={product.id}
name={product.name}
price={getProductPrice(product)}
unit={product.unit || "g"}
image={getProductImage(product)}
stock={product.stock}
category={product.category}
prices={product.prices}
hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)} // ✨ Nouveau prop
/>
</div>
))}
</div>
)}
{selectedCategory === "gros&semi" && (
{isSelectedComingSoon ? (
<div className="coming-soon-overlay">
<span className="coming-soon-text">Prochainement</span>
<p className="coming-soon-desc">
Les produits de cette catégorie arrivent bientôt !
</p>
</div>
) : (
<>
{loading && (
<div className="loading-container">
<p>Chargement des produits...</p>
</div>
)}
{!loading && !error && products.length > 0 && (
<div className="products-grid">
{products.map((product) => (
<div
key={product.id}
data-category={product.category}
>
<ProductCard
id={product.id}
name={product.name}
price={getProductPrice(product)}
unit={product.unit || "g"}
image={getProductImage(product)}
stock={product.stock}
category={product.category}
prices={product.prices}
hasVideo={hasProductVideo(product)}
videoUrl={getProductVideoUrl(product)}
categoryColor={
categories.find(
(c) => c.name.toLowerCase() === product.category?.toLowerCase(),
)?.color
}
/>
</div>
))}
</div>
)}
</>
)}
</div>
</>
+82 -1
View File
@@ -890,4 +890,85 @@
transform: none;
box-shadow: 0 8px 24px rgba(91, 33, 182, 0.5);
}
}
}
/* ============================================
TOGGLE PARRAINAGE
============================================ */
.referral-toggle-box {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.25);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.referral-toggle-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.referral-toggle-icon {
font-size: 1.5rem;
}
.referral-toggle-label {
color: #e5e7eb;
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 0.2rem;
}
.referral-toggle-balance {
color: #8b5cf6;
font-size: 0.82rem;
margin: 0;
}
/* Switch toggle */
.referral-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
flex-shrink: 0;
}
.referral-switch input {
opacity: 0;
width: 0;
height: 0;
}
.referral-switch-slider {
position: absolute;
inset: 0;
background: #374151;
border-radius: 26px;
cursor: pointer;
transition: background 0.2s;
}
.referral-switch-slider::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.referral-switch input:checked + .referral-switch-slider {
background: #8b5cf6;
}
.referral-switch input:checked + .referral-switch-slider::before {
transform: translateX(22px);
}
+57 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCart } from '../../context/CartContext';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated } from '../../api/api';
import { createCheckout, clearCart, extractUsernameFromToken, isUserAuthenticated, getReferralBalance, getPublicSettings } from '../../api/api';
import type { CheckoutData } from '../../api/api';
import Navbar from '../../components/Navbar';
import './Checkout.css';
@@ -23,6 +23,7 @@ interface ConfirmationData {
delivery_address: string;
arrivalTime: string;
total: number;
referral_used?: number;
clientInfo: {
first_name: string;
last_name: string;
@@ -49,6 +50,11 @@ function Checkout() {
const total = cartTotal;
// Parrainage
const [referralBalance, setReferralBalance] = useState(0);
const [referralEnabled, setReferralEnabled] = useState(false);
const [useReferral, setUseReferral] = useState(false);
// ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION
useEffect(() => {
const checkAuth = () => {
@@ -73,6 +79,18 @@ function Checkout() {
return () => clearInterval(authInterval);
}, [navigate]);
// Charger solde parrainage si activé
useEffect(() => {
getPublicSettings().then((settings) => {
if (settings.referral_enabled) {
setReferralEnabled(true);
getReferralBalance().then((res) => {
if (res.success) setReferralBalance(res.balance);
});
}
});
}, []);
/**
* Récupérer le username du JWT
*/
@@ -156,7 +174,8 @@ function Checkout() {
first_name: firstName,
last_name: lastName,
phone,
payment_method: 'especes'
payment_method: 'especes',
use_referral_balance: useReferral && referralBalance > 0,
};
console.log('📤 Envoi checkout avec JWT username:', checkoutData);
@@ -209,6 +228,7 @@ function Checkout() {
delivery_address: delivery_address || address,
arrivalTime,
total: frontendTotal,
referral_used: (response as any).referral_used,
clientInfo: {
first_name: firstName,
last_name: lastName,
@@ -333,6 +353,28 @@ function Checkout() {
</div>
</div>
{/* Toggle parrainage */}
{referralEnabled && referralBalance > 0 && (
<div className="referral-toggle-box">
<div className="referral-toggle-info">
<span className="referral-toggle-icon">🎁</span>
<div>
<p className="referral-toggle-label">Solde parrainage</p>
<p className="referral-toggle-balance">{referralBalance.toFixed(2)} disponible</p>
</div>
</div>
<label className="referral-switch">
<input
type="checkbox"
checked={useReferral}
onChange={(e) => setUseReferral(e.target.checked)}
disabled={loading}
/>
<span className="referral-switch-slider" />
</label>
</div>
)}
<div className="form-actions">
<button
type="button"
@@ -408,6 +450,19 @@ function Checkout() {
</div>
</div>
{/* Parrainage utilisé */}
{confirmationData.referral_used != null && confirmationData.referral_used > 0 && (
<div className="confirmation-section">
<div className="confirmation-section-title">
<i className="fas fa-gift icon"></i>
Parrainage appliqué
</div>
<div className="confirmation-detail">
<strong>Crédit utilisé:</strong> -{confirmationData.referral_used.toFixed(2)}
</div>
</div>
)}
{/* Total */}
<div className="confirmation-total">
<div className="confirmation-total-label">
@@ -222,6 +222,29 @@
font-weight: 600;
}
/* ============================================
PARRAINAGE STAT CARD
============================================ */
.stat-card2.referral-stat:hover {
border-color: #8b5cf6;
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.4);
}
.stat-icon.icon-referral {
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
}
.referral-value-active {
color: #8b5cf6 !important;
}
.referral-link-hint {
color: #6b7280;
font-size: 0.8rem;
margin: 0.25rem 0 0;
}
/* ============================================
LOADING
============================================ */
@@ -8,24 +8,28 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import './ConsultationHistorique.css';
import {
getMyCompletedOrders,
import {
getMyCompletedOrders,
formatPrice,
getOrderAge,
getMyPenalties,
isUserAuthenticated
isUserAuthenticated,
getPublicSettings,
getReferralBalance,
} from '../../api/api';
import type { PublicSettings } from '../../api/api';
import type { CompletedOrder, ClientStats, PenaltyInfo } from "../../api/api_types";
import { Package, MapPin, User, TrendingUp } from 'lucide-react';
// ✅ Import Font Awesome
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
import {
faCannabis,
faWind,
faTrophy,
faExclamationTriangle,
faCheckCircle
faCheckCircle,
faGift,
} from '@fortawesome/free-solid-svg-icons';
function ConsultationHistorique() {
@@ -34,6 +38,8 @@ function ConsultationHistorique() {
const [orders, setOrders] = useState<CompletedOrder[]>([]);
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
const [penalties, setPenalties] = useState<PenaltyInfo | null>(null);
const [appSettings, setAppSettings] = useState<PublicSettings>({ penalties_enabled: true, show_amende_score: true, points_enabled: true, points_separated: true, referral_enabled: true });
const [referralBalance, setReferralBalance] = useState<number>(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string>('');
@@ -64,6 +70,12 @@ function ConsultationHistorique() {
useEffect(() => {
fetchHistory();
fetchPenalties();
getPublicSettings().then((s) => {
setAppSettings(s);
if (s.referral_enabled) {
getReferralBalance().then((r) => { if (r.success) setReferralBalance(r.balance); });
}
});
}, []);
const fetchHistory = async () => {
@@ -177,49 +189,68 @@ function ConsultationHistorique() {
</div>
</div>
{/* Carte 2: Points Weed/Hash - ICÔNE CANNABIS */}
<div className="stat-card2 points-weed">
<div className="stat-icon icon-weed">
<FontAwesomeIcon icon={faCannabis} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faCannabis} style={{ marginRight: '0.5rem' }} />
Points Weed/Hash
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
{/* Cartes points - affichées uniquement si le système de points est activé */}
{appSettings.points_enabled && (
appSettings.points_separated ? (
<>
<div className="stat-card2 points-weed">
<div className="stat-icon icon-weed">
<FontAwesomeIcon icon={faCannabis} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faCannabis} style={{ marginRight: '0.5rem' }} />
Points Weed/Hash
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
{/* ✅ Carte 3: Points Zipette - ICÔNE VENT */}
<div className="stat-card2 points-zipette">
<div className="stat-icon icon-zipette">
<FontAwesomeIcon icon={faWind} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faWind} style={{ marginRight: '0.5rem' }} />
Points Zipette
</p>
<p className="stat-value">{clientStats.points_zipette || 0}</p>
</div>
</div>
<div className="stat-card2 points-zipette">
<div className="stat-icon icon-zipette">
<FontAwesomeIcon icon={faWind} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faWind} style={{ marginRight: '0.5rem' }} />
Points Zipette
</p>
<p className="stat-value">{clientStats.points_zipette || 0}</p>
</div>
</div>
{/* ✅ Carte 4: Total Points - ICÔNE TROPHÉE */}
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Total Points
</p>
<p className="stat-value">
{(clientStats.points || 0) + (clientStats.points_zipette || 0)}
</p>
</div>
</div>
{(clientStats.points || 0) > 0 && (clientStats.points_zipette || 0) > 0 && (
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Total Points
</p>
<p className="stat-value">
{(clientStats.points || 0) + (clientStats.points_zipette || 0)}
</p>
</div>
</div>
)}
</>
) : (
<div className="stat-card2 points-total">
<div className="stat-icon icon-total">
<FontAwesomeIcon icon={faTrophy} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">
<FontAwesomeIcon icon={faTrophy} style={{ marginRight: '0.5rem' }} />
Points
</p>
<p className="stat-value">{clientStats.points || 0}</p>
</div>
</div>
)
)}
{/* Carte 5: Commandes Livrées */}
<div className="stat-card2 completed-orders">
@@ -232,8 +263,8 @@ function ConsultationHistorique() {
</div>
</div>
{/* Carte 6: Pénalités - ICÔNE AVERTISSEMENT */}
{penalties && (
{/* Carte pénalités - affichée uniquement si le score amendes est activé */}
{appSettings.show_amende_score && penalties && (
<div className={`stat-card2 penalty-stat ${penalties.total_penalty > 0 ? 'has-penalty' : ''}`}>
<div
className={`stat-icon ${
@@ -267,6 +298,26 @@ function ConsultationHistorique() {
</div>
</div>
)}
{/* Carte parrainage - affichée si le parrainage est activé */}
{appSettings.referral_enabled && (
<div
className="stat-card2 referral-stat"
onClick={() => navigate('/user/parrainage')}
style={{ cursor: 'pointer' }}
>
<div className="stat-icon icon-referral">
<FontAwesomeIcon icon={faGift} size="lg" />
</div>
<div className="stat-content">
<p className="stat-label">Solde parrainage</p>
<p className={`stat-value ${referralBalance > 0 ? 'referral-value-active' : ''}`}>
{referralBalance.toFixed(2)}
</p>
<p className="referral-link-hint">Voir le programme </p>
</div>
</div>
)}
</div>
)}
+242
View File
@@ -0,0 +1,242 @@
/* ============================================
Parrainage.css - Programme de parrainage
============================================ */
.parrainage-container {
width: 100%;
min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem);
padding-top: calc(60px + clamp(1.5rem, 4vw, 2.5rem));
max-width: 900px;
margin: 0 auto;
background: linear-gradient(to bottom, #0a0a0a, #1a1a1a);
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ============================================
HERO
============================================ */
.parrainage-hero {
text-align: center;
}
.parrainage-hero-icon {
font-size: 3.5rem;
margin-bottom: 0.75rem;
display: block;
}
.parrainage-title {
color: #fff;
font-size: clamp(1.8rem, 5vw, 2.4rem);
font-weight: 700;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, #8b5cf6, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.parrainage-subtitle {
color: #9ca3af;
font-size: clamp(0.95rem, 2.5vw, 1.1rem);
margin: 0;
}
/* ============================================
SOLDE CARD
============================================ */
.parrainage-balance-card {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.05));
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 16px;
padding: 2rem;
text-align: center;
}
.balance-label {
color: #9ca3af;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.75rem;
}
.balance-amount {
color: #6b7280;
font-size: clamp(2.5rem, 7vw, 3.5rem);
font-weight: 700;
line-height: 1;
transition: color 0.2s;
}
.balance-amount.has-balance {
color: #8b5cf6;
}
.balance-loading {
color: #6b7280;
font-size: 1.1rem;
}
.balance-hint {
color: #8b5cf6;
font-size: 0.875rem;
margin-top: 0.5rem;
}
/* ============================================
ÉTAPES
============================================ */
.parrainage-section-title {
color: #e5e7eb;
font-size: 1.2rem;
font-weight: 600;
margin: 0 0 1.25rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.parrainage-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.step-card {
background: #111115;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px;
padding: 1.25rem 1rem;
text-align: center;
transition: border-color 0.2s, transform 0.2s;
}
.step-card:hover {
border-color: rgba(139, 92, 246, 0.35);
transform: translateY(-2px);
}
.step-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.step-num {
color: #8b5cf6;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.35rem;
}
.step-title {
color: #f4f4f5;
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.5rem;
}
.step-desc {
color: #9ca3af;
font-size: 0.82rem;
line-height: 1.5;
margin: 0;
}
/* ============================================
WARNING ZONE MIN
============================================ */
.parrainage-warning-card {
display: flex;
gap: 1rem;
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.25);
border-radius: 12px;
padding: 1.25rem 1.5rem;
align-items: flex-start;
}
.warning-icon {
font-size: 1.5rem;
flex-shrink: 0;
margin-top: 0.1rem;
}
.warning-title {
color: #f59e0b;
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 0.4rem;
}
.warning-text {
color: #d1d5db;
font-size: 0.875rem;
line-height: 1.55;
margin: 0 0 0.75rem;
}
.warning-example {
background: rgba(245, 158, 11, 0.1);
border-radius: 8px;
padding: 0.6rem 0.75rem;
font-size: 0.85rem;
color: #e5e7eb;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.example-label {
color: #f59e0b;
font-weight: 600;
margin-right: 0.25rem;
}
/* ============================================
CTA TELEGRAM
============================================ */
.parrainage-cta {
text-align: center;
padding-bottom: 1rem;
}
.cta-text {
color: #9ca3af;
font-size: 0.9rem;
margin: 0 0 1.25rem;
}
.telegram-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #229ed9;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
padding: 0.8rem 1.8rem;
border-radius: 10px;
text-decoration: none;
transition: background 0.2s, transform 0.15s;
}
.telegram-btn:hover {
background: #1a8bc4;
transform: translateY(-1px);
}
.telegram-icon {
font-size: 1.1rem;
}
+137
View File
@@ -0,0 +1,137 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import { getReferralBalance, isUserAuthenticated } from '../../api/api';
import './Parrainage.css';
const TELEGRAM_URL = 'https://t.me/';
const steps = [
{
num: 1,
title: 'Parrainez un ami',
desc: 'Recommandez nos services à un proche. Il doit nous contacter directement sur Telegram pour s\'inscrire.',
icon: '👥',
},
{
num: 2,
title: 'Il passe sa 1ère commande',
desc: 'Une fois votre ami inscrit et sa première commande validée, signalez-le nous sur Telegram.',
icon: '✅',
},
{
num: 3,
title: 'Nous créditons votre compte',
desc: 'L\'admin vérifie et crédite manuellement votre solde de parrainage. Vous êtes notifié dès que c\'est fait.',
icon: '💰',
},
{
num: 4,
title: 'Utilisez votre solde',
desc: 'Au moment du checkout, choisissez d\'utiliser votre solde ou de le cumuler pour une prochaine commande.',
icon: '🛒',
},
];
export default function Parrainage() {
const navigate = useNavigate();
const [balance, setBalance] = useState<number>(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isUserAuthenticated()) {
navigate('/login/client', { replace: true });
return;
}
getReferralBalance().then((res) => {
if (res.success) setBalance(res.balance);
setLoading(false);
});
}, [navigate]);
return (
<>
<Navbar />
<div className="parrainage-container">
{/* En-tête */}
<div className="parrainage-hero">
<div className="parrainage-hero-icon">🎁</div>
<h1 className="parrainage-title">Programme de Parrainage</h1>
<p className="parrainage-subtitle">
Parrainez vos amis et cumulez du crédit sur votre compte
</p>
</div>
{/* Solde actuel */}
<div className="parrainage-balance-card">
<div className="balance-label">Votre solde parrainage</div>
{loading ? (
<div className="balance-loading">Chargement...</div>
) : (
<div className={`balance-amount ${balance > 0 ? 'has-balance' : ''}`}>
{balance.toFixed(2)}
</div>
)}
{balance > 0 && (
<p className="balance-hint">
Utilisable au prochain checkout&nbsp;
</p>
)}
</div>
{/* Étapes */}
<div className="parrainage-steps-section">
<h2 className="parrainage-section-title">Comment ça marche ?</h2>
<div className="parrainage-steps">
{steps.map((step) => (
<div key={step.num} className="step-card">
<div className="step-icon">{step.icon}</div>
<div className="step-num">Étape {step.num}</div>
<h3 className="step-title">{step.title}</h3>
<p className="step-desc">{step.desc}</p>
</div>
))}
</div>
</div>
{/* Règle zone minimum */}
<div className="parrainage-warning-card">
<div className="warning-icon"></div>
<div className="warning-content">
<h3 className="warning-title">Règle du minimum de zone</h3>
<p className="warning-text">
Même avec un solde parrainage, vous devez atteindre le minimum de
commande de votre zone. Votre crédit couvre la différence mais ne
remplace pas le minimum requis.
</p>
<div className="warning-example">
<span className="example-label">Exemple :</span>
<span>
Solde <strong>50 </strong> + Zone minimum <strong>50 </strong>{' '}
Commande totale minimum <strong>100 </strong> (vous payez{' '}
<strong>50 </strong>)
</span>
</div>
</div>
</div>
{/* Bouton Telegram */}
<div className="parrainage-cta">
<p className="cta-text">
Prêt à parrainer ? Contactez-nous sur Telegram pour enregistrer votre
filleul.
</p>
<a
href={TELEGRAM_URL}
target="_blank"
rel="noopener noreferrer"
className="telegram-btn"
>
<span className="telegram-icon"></span>
Contacter sur Telegram
</a>
</div>
</div>
</>
);
}
+15 -15
View File
@@ -79,8 +79,8 @@
}
.product-image-section:hover {
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.15);
border-color: rgba(var(--cat-color-rgb, 124, 58, 237), 0.3);
box-shadow: 0 12px 40px rgba(var(--cat-color-rgb, 124, 58, 237), 0.15);
}
.product-detail-image {
@@ -177,7 +177,7 @@
.product-description {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-left: 3px solid #7c3aed;
border-left: 3px solid var(--cat-color, #7c3aed);
border-radius: 12px;
padding: clamp(1.25rem, 3vw, 1.75rem);
backdrop-filter: blur(8px);
@@ -186,8 +186,8 @@
}
.product-description:hover {
border-color: rgba(124, 58, 237, 0.3);
box-shadow: 0 6px 20px rgba(124, 58, 237, 0.15);
border-color: rgba(var(--cat-color-rgb, 124, 58, 237), 0.3);
box-shadow: 0 6px 20px rgba(var(--cat-color-rgb, 124, 58, 237), 0.15);
}
.product-description h3 {
@@ -258,14 +258,14 @@
.grams-dropdown:hover {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
border-color: rgba(124, 58, 237, 0.5);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
border-color: rgba(var(--cat-color-rgb, 124, 58, 237), 0.5);
box-shadow: 0 4px 12px rgba(var(--cat-color-rgb, 124, 58, 237), 0.2);
}
.grams-dropdown:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
border-color: var(--cat-color, #7c3aed);
box-shadow: 0 0 0 3px rgba(var(--cat-color-rgb, 124, 58, 237), 0.2);
}
.grams-dropdown:disabled {
@@ -289,8 +289,8 @@
}
.grams-dropdown option:checked {
background-color: #7c3aed;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
background-color: var(--cat-color, #7c3aed);
background: var(--cat-color, #7c3aed);
color: white;
}
@@ -343,7 +343,7 @@
/* Add to Cart Button */
.add-to-cart-button {
width: 100%;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
background: var(--cat-color, #7c3aed);
color: white;
border: none;
border-radius: 12px;
@@ -356,7 +356,7 @@
-webkit-tap-highlight-color: transparent;
text-transform: uppercase;
letter-spacing: 1.5px;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.4);
box-shadow: 0 0 30px rgba(var(--cat-color-rgb, 124, 58, 237), 0.4);
position: relative;
overflow: hidden;
}
@@ -378,7 +378,7 @@
.add-to-cart-button:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(124, 58, 237, 0.5);
box-shadow: 0 12px 40px rgba(var(--cat-color-rgb, 124, 58, 237), 0.5);
}
.add-to-cart-button:active {
@@ -514,7 +514,7 @@
@media (hover: none) {
.add-to-cart-button:hover {
transform: none;
box-shadow: 0 0 30px rgba(124, 58, 237, 0.4);
box-shadow: 0 0 30px rgba(var(--cat-color-rgb, 124, 58, 237), 0.4);
}
.add-to-cart-button:hover::before {
+26 -3
View File
@@ -1,6 +1,6 @@
import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { getProductById, isUserAuthenticated } from "../../api/api";
import { getProductById, getCategories, isUserAuthenticated } from "../../api/api";
import type { Product } from "../../api/api";
import { useCart } from "../../context/CartContext";
import Navbar from "../../components/Navbar";
@@ -16,6 +16,8 @@ function ProductDetail() {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [catColor, setCatColor] = useState<string>("#7c3aed");
// floats
const [selectedGrams, setSelectedGrams] = useState<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(0.0);
@@ -75,7 +77,10 @@ function ProductDetail() {
setError(null);
try {
const response = await getProductById(productId);
const [response, categories] = await Promise.all([
getProductById(productId),
getCategories(),
]);
if (response.success && response.data) {
const fixedProduct = {
@@ -96,6 +101,12 @@ function ProductDetail() {
setSelectedGrams(fixedProduct.prices[0].quantity);
setSelectedPrice(fixedProduct.prices[0].price);
}
// Couleur de la catégorie depuis la DB
const matched = categories.find(
(c) => c.name.toLowerCase() === (response.data.category || "").toLowerCase(),
);
if (matched?.color) setCatColor(matched.color);
} else {
setError(response.message || "Produit non trouvé");
}
@@ -188,6 +199,15 @@ function ProductDetail() {
const isOutOfStock = product.stock === 0;
const hasValidPrices = product.prices && product.prices.length > 0;
// Convertir la couleur hex en valeurs RGB pour les CSS rgba()
const hexToRgb = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
};
const catColorRgb = hexToRgb(catColor);
return (
<>
<Navbar />
@@ -202,7 +222,10 @@ function ProductDetail() {
/>
)}
<div className="product-detail-container">
<div
className="product-detail-container"
style={{ "--cat-color": catColor, "--cat-color-rgb": catColorRgb } as React.CSSProperties}
>
<button onClick={() => navigate(-1)} className="back-button">
Retour
</button>
@@ -244,6 +244,38 @@
white-space: nowrap;
}
.eta-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border-radius: 20px;
background: linear-gradient(135deg, #065f46 0%, #059669 100%);
color: #d1fae5;
font-size: clamp(0.75rem, 2vw, 0.85em);
font-weight: 700;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.4);
animation: eta-pulse 2s ease-in-out infinite;
}
@keyframes eta-pulse {
0%, 100% { box-shadow: 0 2px 8px rgba(5, 150, 105, 0.4); }
50% { box-shadow: 0 2px 16px rgba(5, 150, 105, 0.7); }
}
.eta-badge--preRoute {
background: linear-gradient(135deg, #78350f 0%, #d97706 100%);
color: #fef3c7;
box-shadow: 0 2px 8px rgba(217, 119, 6, 0.4);
animation: eta-pulse-amber 2s ease-in-out infinite;
}
@keyframes eta-pulse-amber {
0%, 100% { box-shadow: 0 2px 8px rgba(217, 119, 6, 0.4); }
50% { box-shadow: 0 2px 16px rgba(217, 119, 6, 0.7); }
}
.order-header-right {
display: flex;
align-items: center;
+29 -16
View File
@@ -118,8 +118,6 @@ const getStatusColor = (status: string): string => {
return 'linear-gradient(135deg, #ddd6fe 0%, #a78bfa 50%, #7c3aed 100%)';
case 'assigned':
return 'linear-gradient(135deg, #fef3c7 0%, #fbbf24 50%, #f59e0b 100%)';
case 'support':
return 'linear-gradient(135deg, #e9d5ff 0%, #c084fc 50%, #9333ea 100%)';
case 'en_route':
return 'linear-gradient(135deg, #bfdbfe 0%, #60a5fa 50%, #3b82f6 100%)';
case 'arrived':
@@ -139,7 +137,6 @@ const getStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
'pending': 'En attente d\'assignation',
'assigned': 'Livreur assigné',
'support': 'Pris en charge par le livreur',
'en_route': 'En route vers vous',
'arrived': 'Livreur arrivé',
'livre': 'Livré - À confirmer',
@@ -154,7 +151,6 @@ const getStatusIcon = (status: string): any => {
const iconMap: Record<string, any> = {
'pending': faHourglassHalf,
'assigned': faBiking,
'support': faTruck,
'en_route': faTruck,
'arrived': faMapMarkerAlt,
'livre': faBox,
@@ -169,7 +165,6 @@ const getStatusProgress = (status: string): number => {
const progressMap: Record<string, number> = {
'pending': 0,
'assigned': 10,
'support': 25,
'en_route': 50,
'arrived': 80,
'livre': 90,
@@ -444,14 +439,15 @@ function SuiviLivraison() {
if (response.success) {
const pointsEarned = response.points_earned || selectedOrderPoints;
const responseData = (response as any).data || {};
const apiCategory = responseData.category || '';
const apiCategory = response.category || (response as any).data?.category || '';
let displayCategory = selectedOrderCategoryDisplay;
if (apiCategory.toLowerCase().includes('zipette')) {
if (apiCategory === 'total') {
displayCategory = '🏆 Total';
} else if (apiCategory.toLowerCase().includes('zipette')) {
displayCategory = '💨 Zipette&Co';
} else if (apiCategory.toLowerCase().includes('weed') || apiCategory.toLowerCase().includes('hash')) {
displayCategory = '🌿 Weeds&Hash';
displayCategory = '🌿 Weed&Hash';
}
showToast(
@@ -604,12 +600,21 @@ function SuiviLivraison() {
>
<div className="order-header-left">
<h3>Commande #{order.id}</h3>
<div
<div
className="status-badge"
style={{ backgroundImage: getStatusColor(order.status) }}
>
<FontAwesomeIcon icon={getStatusIcon(order.status)} /> {getStatusLabel(order.status)}
</div>
{order.eta?.eta_available && order.eta.eta_minutes > 0 && (
<div className={`eta-badge${order.status?.toLowerCase() === 'assigned' ? ' eta-badge--preRoute' : ''}`}>
<FontAwesomeIcon icon={faClock} />
{order.status?.toLowerCase() === 'assigned'
? ` Arrivée estimée ~${order.eta.eta_minutes} min`
: ` ~${order.eta.eta_minutes} min`}
{order.eta.estimated_arrival && ` (${order.eta.estimated_arrival})`}
</div>
)}
</div>
<div className="order-header-right">
@@ -660,12 +665,20 @@ function SuiviLivraison() {
</div>
)}
{order.eta && (
{order.eta?.eta_available && order.eta.eta_minutes > 0 && (
<div className="detail-section">
<h4><FontAwesomeIcon icon={faClock} /> Heure estimée d'arrivée</h4>
<p>{order.eta.estimated_arrival || `${order.eta.eta_minutes} minutes`}</p>
{order.eta.updated_at && (
<small>Mise à jour: {new Date(order.eta.updated_at * 1000).toLocaleTimeString()}</small>
<h4>
<FontAwesomeIcon icon={faClock} />
{order.status?.toLowerCase() === 'assigned'
? ' Temps d\'arrivée estimé'
: ' Heure estimée d\'arrivée'}
</h4>
<p>~{order.eta.eta_minutes} min{order.eta.estimated_arrival ? ` — arrivée vers ${order.eta.estimated_arrival}` : ''}</p>
{order.status?.toLowerCase() === 'assigned' && (
<small>Le livreur n'a pas encore démarré estimation basée sur sa position actuelle</small>
)}
{order.eta.livreur_distance != null && (
<small>Distance : {typeof order.eta.livreur_distance === 'number' ? order.eta.livreur_distance.toFixed(1) : order.eta.livreur_distance} km</small>
)}
</div>
)}
+13 -99
View File
@@ -19,21 +19,19 @@
position: relative;
}
/* ===== COMING SOON OVERLAY (Gros&Semi) ===== */
/* ===== COMING SOON OVERLAY ===== */
.coming-soon-overlay {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
padding-bottom: 12vh;
pointer-events: none;
z-index: 10;
align-items: center;
padding: 80px 24px;
gap: 16px;
}
.coming-soon-text {
font-family: "Reach fill & Outline", sans-serif;
font-size: clamp(1.5rem, 8vw, 0rem);
font-size: clamp(1.5rem, 8vw, 4rem);
font-weight: 400;
color: #8e8fe8;
letter-spacing: 4px;
@@ -46,6 +44,13 @@
animation: pulse-scale 2s ease-in-out infinite;
}
.coming-soon-desc {
color: rgba(142, 143, 232, 0.7);
font-size: 1rem;
text-align: center;
margin: 0;
}
@keyframes pulse-scale {
0%,
100% {
@@ -95,31 +100,6 @@
border-color: white;
}
/* Effets néon par catégorie - BOUTONS */
.category-button.active[data-category="tous"] {
background-color: #9333ea;
color: white;
border-color: #9333ea;
}
.category-button.active[data-category="weed&hash"] {
background-color: #10b981;
color: white;
border-color: #10b981;
}
.category-button.active[data-category="zipette&co"] {
background-color: #f5f5f0;
color: black;
border-color: #f5f5f0;
}
.category-button.active[data-category="gros&semi"] {
background-color: #3dc2f7;
color: white;
border-color: #3dc2f7;
}
/* ===== CATEGORY HEADER ===== */
.category-header {
margin-bottom: clamp(2rem, 5vw, 3rem);
@@ -187,48 +167,6 @@
scroll-snap-stop: always;
}
/* Effets néon par catégorie - CONTAINERS DE PRODUITS */
/* Catégorie: tous - VIOLET - Effet néon amélioré */
.products-grid > div[data-category="tous"] .product-card {
border: 2px solid #9333ea;
box-shadow:
0 0 20px rgba(147, 51, 234, 0.6),
0 0 40px rgba(147, 51, 234, 0.4),
0 0 60px rgba(147, 51, 234, 0.2),
0 0 80px rgba(147, 51, 234, 0.1);
}
/* Catégorie: weed&hash - VERT - Effet néon amélioré */
.products-grid > div[data-category="weed&hash"] .product-card {
border: 2px solid #10b981;
box-shadow:
0 0 20px rgba(16, 185, 129, 0.6),
0 0 40px rgba(16, 185, 129, 0.4),
0 0 60px rgba(16, 185, 129, 0.2),
0 0 80px rgba(16, 185, 129, 0.1);
}
/* Catégorie: zipette&co - BLANC CASSÉ - Effet néon amélioré */
.products-grid > div[data-category="zipette&co"] .product-card {
border: 2px solid #f5f5f0;
box-shadow:
0 0 20px rgba(245, 245, 240, 0.6),
0 0 40px rgba(245, 245, 240, 0.4),
0 0 60px rgba(245, 245, 240, 0.2),
0 0 80px rgba(245, 245, 240, 0.1);
}
/* Catégorie: gros&semi - BLEU CIEL - Effet néon amélioré */
.products-grid > div[data-category="gros&semi"] .product-card {
border: 2px solid #3dc2f7;
box-shadow:
0 0 20px rgba(61, 194, 247, 0.6),
0 0 40px rgba(61, 194, 247, 0.4),
0 0 60px rgba(61, 194, 247, 0.2),
0 0 80px rgba(61, 194, 247, 0.1);
}
/* Petits téléphones */
@media (max-width: 360px) {
.products-grid {
@@ -295,30 +233,6 @@
color: black;
}
.category-button.active[data-category="tous"]:hover {
background-color: #9333ea;
color: white;
}
.category-button.active[data-category="weed&hash"]:hover {
background-color: #10b981;
color: white;
}
.category-button.active[data-category="zipette&co"]:hover {
background-color: #f5f5f0;
color: white;
}
.category-button.active[data-category="gros&semi"]:hover {
background-color: #3dc2f7;
color: white;
}
.category-button.active[data-category="festif"]:hover {
background-color: #9333ea;
color: white;
}
}
.loading-container,