Author SHA1 Message Date
Xor290 97381b8b68 chore: add pre-prod branch in CI backend 2026-05-09 15:25:12 +02:00
Xor290 8ebb6d2370 chore: add backend 2026-05-09 15:22:24 +02:00
Xor290 eb8ba01159 chore: delete ci 2026-05-09 15:21:10 +02:00
Xor290 1672dc1e20 feat: add 2FA and change title 2026-05-09 15:17:19 +02:00
Xor290 771f514af3 chore: add clamav 2026-05-08 19:02:03 +02:00
Xor290 ec056b49bc fix: icone 2026-05-03 22:59:45 +02:00
Xor290 77f1d9db2c fix: icone 2026-05-03 22:16:37 +02:00
Xor290 4a6265f3a9 chore 2026-05-03 21:41:25 +02:00
Xor290 e89724bd28 fix: ci 2026-05-03 21:38:46 +02:00
Xor290 3abc825aee chore: build 2026-05-03 21:34:59 +02:00
Xor290 4e4605f400 chore: build 2026-05-03 21:22:20 +02:00
Xor290 48c8e0ea66 chore: fix CI 2026-05-03 21:18:57 +02:00
Xor290 cf28fad1a9 chore: build 2026-05-03 21:17:41 +02:00
Xor290 60192f3748 chore: fix 2026-05-03 20:52:23 +02:00
Xor290 afbe18b896 chore: CI 2026-05-03 20:48:20 +02:00
Xor290andGitHub 68c3a25c26 Merge pull request #1 from UBARSTUPAR/pre-prod
Pre prod
2026-05-03 20:37:04 +02:00
Xor290 38c73a15a1 chore: fix 2026-05-03 20:32:38 +02:00
Xor290 30e387aeed Merge branch 'main' into pre-prod 2026-05-03 20:30:02 +02:00
Xor290 187ce1b187 chore: update pipeline 2026-05-03 20:24:18 +02:00
Xor290 b1fa0ad608 chore: fix 2026-05-03 19:46:12 +02:00
Xor290 37ae4117b4 chore: fix 2026-05-03 19:44:35 +02:00
Xor290 f8473b9a54 chore: fix 2026-05-03 19:38:29 +02:00
Xor290 4075ae2568 chore: fix icones 2026-05-03 18:25:33 +02:00
Xor290 7559ec6c5d feat: detect telegram notif is enabled or no 2026-05-03 17:14:20 +02:00
Xor290 812cf00cef feat: detect telegram notif is enabled or no 2026-05-03 17:12:38 +02:00
Xor290 387986d6f5 feat: notif telegram 2026-05-03 15:11:13 +02:00
Xor290 74d499e328 fix: url 2026-05-03 14:47:09 +02:00
Xor290 b323cf298c chore: fix url 2026-05-03 13:44:25 +02:00
Xor290 78cdf074e2 chore: fix ts 2026-05-03 13:26:20 +02:00
Xor290 7dc2686f6c feat: add CSV export for approved orders && add non-delivery reason modal for livreurs 2026-05-03 13:23:20 +02:00
Xor290 a273484659 chore: delete dependency 2026-05-03 12:42:54 +02:00
Xor290 e6d9b09016 chore: update backend 2026-05-03 12:31:45 +02:00
Xor290 e4ddfaf8af chore: fix ts 2026-05-03 12:16:28 +02:00
Xor290 a4f3497a78 chore: build 2026-05-03 12:14:29 +02:00
Xor290 3c6f35c225 chore: update CI mobile 2026-05-02 18:14:24 +02:00
Xor290 cd6c13d3a5 chore: add url pre-prod 2026-05-02 18:13:00 +02:00
Xor290 90a23b065b chore: add CD 2026-05-02 18:10:33 +02:00
Xor290 a9d8fb5297 ci: add CD pipeline with Docker Hub push on pre-prod 2026-05-02 17:58:09 +02:00
373 changed files with 100758 additions and 7341 deletions
+114
View File
@@ -0,0 +1,114 @@
name: Backend - Build & Lint
on:
push:
branches: [main, pre-prod]
paths:
- "backend/**/**"
pull_request:
branches: [main, pre-prod]
paths:
- "backend/**/**"
jobs:
lint:
name: Static Analysis (golangci-lint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
cache-dependency-path: backend/gestion/go.sum
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
continue-on-error: true
with:
version: latest
working-directory: backend/gestion
args: --timeout=5m
build:
name: Build
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
cache-dependency-path: backend/gestion/go.sum
- name: Download dependencies
working-directory: backend/gestion
run: go mod download
- name: Build
working-directory: backend/gestion
run: go build -v ./...
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: backend-binary
path: backend/gestion/gestion
retention-days: 7
docker:
name: Docker Build & Push
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & push backend (runtime)
uses: docker/build-push-action@v6
with:
context: .
file: docker/backend/Dockerfile
target: runtime
push: true
tags: xor1234/backend-mln:latest
- name: Build & push WAF
uses: docker/build-push-action@v6
with:
context: .
file: docker/backend/Dockerfile
target: waf
push: true
tags: xor1234/backend-mln:waf
deploy:
name: SSH Deploy
needs: docker
runs-on: ubuntu-latest
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
script: |
docker compose -f ${{ secrets.COMPOSE_PATH }} pull backend waf
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps backend waf
@@ -0,0 +1,82 @@
name: Frontend Admin - EAS Build
on:
push:
branches: [main, pre-prod]
paths:
- "frontend-admin/**"
pull_request:
branches: [main, pre-prod]
paths:
- "frontend-admin/**"
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend-admin/package-lock.json
- name: Install dependencies
working-directory: frontend-admin
run: npm ci
- name: Typecheck
working-directory: frontend-admin
run: npx tsc --noEmit
build-apk:
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend-admin/package-lock.json
- name: Setup Expo & EAS CLI
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
working-directory: frontend-admin
run: npm ci
- name: Inject EAS project ID
working-directory: frontend-admin
run: |
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Build APK
working-directory: frontend-admin
env:
EAS_BUILD_NO_EXPO_GO_WARNING: true
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
- name: Download APK
working-directory: frontend-admin
run: |
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
curl -L -o admin-panel-prod.apk "$APK_URL"
- name: Upload production APK artifact
uses: actions/upload-artifact@v4
with:
name: admin-panel-android-prod-apk
path: frontend-admin/admin-panel-prod.apk
retention-days: 14
@@ -0,0 +1,86 @@
name: Frontend Client - EAS Build
on:
push:
branches: [main, pre-prod]
paths:
- "mobile/**"
pull_request:
branches: [main, pre-prod]
paths:
- "mobile/**"
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: mobile/package-lock.json
- name: Install dependencies
working-directory: mobile
run: npm ci
- name: Typecheck
working-directory: mobile
run: npx tsc --noEmit
build-apk:
needs: typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: mobile/package-lock.json
- name: Setup Expo & EAS CLI
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
working-directory: mobile
run: npm ci
- name: Inject EAS project ID
working-directory: mobile
run: |
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Debug app.json
working-directory: mobile
run: cat app.json
- name: Build APK
working-directory: mobile
env:
EAS_BUILD_NO_EXPO_GO_WARNING: true
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive
- name: Download production APK
working-directory: mobile
run: |
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
curl -L -o client-panel-prod.apk "$APK_URL"
- name: Upload production APK artifact
uses: actions/upload-artifact@v4
with:
name: client-panel-android-prod-apk
path: mobile/client-panel-prod.apk
retention-days: 14
+111
View File
@@ -0,0 +1,111 @@
name: Frontend Web - Build & Lint
on:
push:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker/frontend/**"
pull_request:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker/frontend/**"
jobs:
lint-typecheck:
name: Lint & Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend-prep/package-lock.json
- name: Install dependencies
working-directory: frontend-prep
run: npm ci
- name: Typecheck
working-directory: frontend-prep
run: npx tsc -b --noEmit
- name: Lint
working-directory: frontend-prep
run: npm run lint
build:
name: Build
needs: lint-typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend-prep/package-lock.json
- name: Install dependencies
working-directory: frontend-prep
run: npm ci
- name: Build
working-directory: frontend-prep
env:
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
run: npm run build
docker:
name: Docker Build & Push
needs: build
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/pre-prod')) ||
(github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'pre-prod'))
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & push frontend
uses: docker/build-push-action@v6
with:
context: .
file: docker/frontend/Dockerfile
push: true
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
build-args: |
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
deploy:
name: Deploy to server
needs: docker
runs-on: ubuntu-latest
steps:
- name: SSH deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_HOST_PROD || secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && secrets.SERVER_SSH_KEY_PROD || secrets.SERVER_SSH_KEY }}
script: |
docker compose -f ${{ secrets.COMPOSE_PATH }} pull frontend
docker compose -f ${{ secrets.COMPOSE_PATH }} up -d --no-deps frontend
+6 -4
View File
@@ -1,8 +1,10 @@
# Expo local state (in sub-projects)
**/.expo/
test_address.sh
easpip
ansible/
dist/
docker-prod/
.ssh
mc_utilisation
monitoring/*/certs
frontend-prep2/
scripts/data.txt
scripts/data2.txt
scripts/data3.txt
-339
View File
@@ -1,339 +0,0 @@
# Infrastructure — Projet Gestion Commande
## Schéma global
```mermaid
graph TB
%% ─── Clients ───────────────────────────────────────────────
subgraph Clients["Clients"]
WEB["🌐 Web\nReact / Vite\n(frontend-prep)"]
MOB["📱 Mobile client\nReact Native / Expo\n(mobile/)"]
ADM["📱 Mobile admin\nReact Native / Expo\n(frontend-admin/)"]
end
%% ─── Services externes ──────────────────────────────────────
subgraph External["Services externes"]
TOMTOM["TomTom API\nMaps & routing"]
TELEGRAM["Telegram\nBot / Webhooks"]
NOWPAY["NowPayments\nCrypto IPN"]
DOCKERHUB["Docker Hub\nxor1234/backend-mln\nxor1234/frontend-mln"]
EAS["Expo EAS\nAPK builds"]
end
%% ─── CI/CD ──────────────────────────────────────────────────
subgraph CICD["CI/CD — GitHub Actions"]
GHA_B_PP["backend-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
GHA_F_PP["frontend-web-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
GHA_B["backend-build.yml (main)\nlint → build → docker → SSH deploy"]
GHA_F["frontend-web-build.yml (main)\nlint → build → docker → SSH deploy"]
GHA_A["frontend-admin-build.yml\ntypecheck → EAS APK"]
GHA_C["frontend-client-build.yml\ntypecheck → EAS APK"]
end
%% ─── VPS Pre-prod ───────────────────────────────────────────
subgraph PreProdVPS["VPS Pre-prod"]
subgraph GestionNetPP["Docker network : gestion-network"]
WAF_PP["🛡️ WAF\nnginx + ModSecurity CRS\n:80 / :443 ← public"]
BACK_PP["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
FRONT_PP["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
PG_PP["🗄️ PostgreSQL 16\n← interne"]
REDIS_PP["⚡ Redis 7\n← interne"]
end
end
%% ─── VPS Production ─────────────────────────────────────────
subgraph ProdVPS["VPS Production — mln-uber.club"]
subgraph GestionNet["Docker network : gestion-network"]
WAF["🛡️ WAF\nnginx + ModSecurity CRS\nParanoia L2\n:80 / :443 ← public"]
BACK["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
FRONT["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
PG["🗄️ PostgreSQL 16\ngestion_db\n← interne"]
REDIS["⚡ Redis 7\nSessions · Queue · Cache\n256 MB LRU ← interne"]
end
end
%% ─── VPS Monitoring ─────────────────────────────────────────
subgraph MonVPS["VPS Monitoring — uber-stup.club"]
subgraph MonNet["Docker network : monitoring_net"]
MNGINX["🔀 Nginx RP\n:80 / :443 ← public\ndozzle / wazuh\nrustfs / s3 / ota"]
DOZZLE["📋 Dozzle\nLogs temps réel\n:8080 ← interne"]
WAZUH_M["🔍 Wazuh Manager\n:1514 agents\n:1515 enroll\n:514 syslog"]
WAZUH_I["🗂️ Wazuh Indexer\nOpenSearch :9200"]
WAZUH_D["📊 Wazuh Dashboard\nKibana :5601"]
RUSTFS["🗃️ RustFS\nS3-compatible :9000\nConsole :9001"]
XAVIA["🚀 Xavia OTA\nNext.js :3000"]
XAVIA_DB["🗄️ PostgreSQL 16\nxavia_db ← interne"]
end
end
%% ─── Flux clients ───────────────────────────────────────────
WEB -->|HTTPS| WAF
MOB -->|HTTPS| WAF
ADM -->|HTTPS| WAF
%% ─── Routage WAF ────────────────────────────────────────────
WAF -->|"/api/*"| BACK
WAF -->|"/* SPA"| FRONT
WAF -->|"/uploads/*"| BACK
%% ─── Backend ↔ données ──────────────────────────────────────
BACK --> PG
BACK --> REDIS
%% ─── Backend ↔ services externes ────────────────────────────
BACK -->|"Geocoding / ETA"| TOMTOM
BACK -->|"Webhook"| TELEGRAM
BACK -->|"IPN callback"| NOWPAY
%% ─── CI/CD ──────────────────────────────────────────────────
GHA_B_PP -->|"push :latest + :waf"| DOCKERHUB
GHA_F_PP -->|"push :latest"| DOCKERHUB
GHA_B_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
GHA_F_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
GHA_B -->|"push :latest + :waf"| DOCKERHUB
GHA_F -->|"push :latest"| DOCKERHUB
GHA_B -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
GHA_F -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
GHA_A -->|"eas build android"| EAS
GHA_C -->|"eas build android"| EAS
DOCKERHUB -->|"docker pull"| WAF_PP
DOCKERHUB -->|"docker pull"| BACK_PP
DOCKERHUB -->|"docker pull"| FRONT_PP
DOCKERHUB -->|"docker pull"| WAF
DOCKERHUB -->|"docker pull"| BACK
DOCKERHUB -->|"docker pull"| FRONT
%% ─── CI/CD mobile → OTA ────────────────────────────────────
GHA_A -->|"eas build pre-prod"| XAVIA
GHA_C -->|"eas build pre-prod"| XAVIA
%% ─── Monitoring ─────────────────────────────────────────────
MNGINX --> DOZZLE
MNGINX --> WAZUH_D
MNGINX -->|"rustfs.uber-stup.club"| RUSTFS
MNGINX -->|"s3.uber-stup.club"| RUSTFS
MNGINX -->|"ota.uber-stup.club"| XAVIA
WAZUH_D --> WAZUH_I
WAZUH_M --> WAZUH_I
XAVIA --> XAVIA_DB
DOZZLE -->|"remote agent :7007"| ProdVPS
DOZZLE -->|"remote agent :7007"| PreProdVPS
```
---
## VPS Production
**Domaine** : `mln-uber.club`
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | Reverse proxy + WAF ModSecurity |
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données principale |
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions · File livreurs |
### Flux de trafic
```mermaid
flowchart TD
INET["🌐 Internet"] -->|":443 TLS 1.2/1.3"| WAF
subgraph WAF["🛡️ WAF — nginx + ModSecurity CRS L2"]
W1["Rate limit : 20 req/s/IP"]
W2["HSTS 2 ans · CSP · X-Frame"]
W3["Ban auto : SQLi / XSS / LFI / RCE"]
end
WAF -->|"/api/*"| BACK["⚙️ Backend\nGo / Gin :8080"]
WAF -->|"/* SPA"| FRONT["🖥️ Frontend\nnginx :80"]
WAF -->|"/uploads/*\n(fichiers statiques)"| BACK
BACK --> PG["🗄️ PostgreSQL 16"]
BACK --> REDIS["⚡ Redis 7\nSessions · Queue · Cache"]
```
### Volumes persistants
| Volume | Usage |
|---|---|
| `postgres_data` | Données PostgreSQL |
| `redis_data` | Persistance Redis (AOF) |
| `backend_uploads` | Fichiers uploadés — monté en `:ro` dans le WAF pour `/uploads/` |
### Variables d'environnement requises
| Variable | Valeur par défaut | Description |
|---|---|---|
| `DB_PASSWORD` | — | Mot de passe PostgreSQL |
| `DB_NAME` | `gestion_db` | Nom de la base |
| `SESSION_SECRET` | — | Secret session Gin |
| `USER_JWT_SECRET` | — | JWT clients |
| `USER_JWT_SECRET_OLD` | — | JWT clients (rotation) |
| `ADMIN_JWT_SECRET` | — | JWT admin/cabine |
| `ADMIN_JWT_SECRET_OLD` | — | JWT admin (rotation) |
| `REDIS_PASSWORD` | — | Mot de passe Redis |
| `TOMTOM_API_KEY` | — | Clé TomTom Maps |
| `TELEGRAM_WEBHOOK_URL` | — | URL webhook Telegram |
| `TELEGRAM_WEBHOOK_SECRET` | — | Secret webhook Telegram |
| `NOWPAYMENTS_IPN_SECRET` | — | Secret IPN NowPayments |
---
## VPS Pre-prod
Même stack que la production, déployé depuis la branche `pre-prod` via `SERVER_HOST` / `SERVER_SSH_KEY`.
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | WAF ModSecurity |
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données |
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions |
---
## VPS Monitoring
**Domaine** : `uber-stup.club`
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `monitoring_nginx` | `nginx:alpine` | **80, 443** (public) | Reverse proxy monitoring |
| `dozzle` | `amir20/dozzle:latest` | 8080 (interne) | Logs Docker temps réel |
| `wazuh.manager` | `wazuh/wazuh-manager:4.14.5` | 1514, 1515, 514/udp | SIEM — collecte agents |
| `wazuh.indexer` | `wazuh/wazuh-indexer:4.14.5` | 9200 (interne) | OpenSearch (stockage events) |
| `wazuh.dashboard` | `wazuh/wazuh-dashboard:4.14.5` | 5601 (interne) | Kibana (visualisation) |
| `rustfs` | `rustfs/rustfs:latest` | 9000 S3, 9001 console (internes) | Stockage objet S3-compatible (APKs) |
| `xavia` | `xaviaio/xavia-ota:latest` | 3000 (interne) | Serveur OTA Expo (Next.js) |
| `xavia_db` | `postgres:16-alpine` | 5432 (interne) | Base de données Xavia |
### Accès publics
| URL | Service |
|---|---|
| `https://dozzle.uber-stup.club` | Interface logs Docker |
| `https://wazuh.uber-stup.club` | Dashboard SIEM Wazuh |
| `https://rustfs.uber-stup.club` | Console RustFS (stockage APKs) |
| `https://s3.uber-stup.club` | API S3 RustFS |
| `https://ota.uber-stup.club` | Dashboard & API Xavia OTA |
### Xavia OTA — configuration app Expo
```json
"updates": {
"url": "https://ota.uber-stup.club/api/manifest",
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": {
"keyid": "main",
"algorithm": "rsa-v1_5-sha256"
}
}
```
Clé privée RSA 4096 stockée sur le serveur dans `/home/ubuntu/xavia-keys/private-key.pem`.
Le `certificate.pem` doit être commité dans le repo mobile sous `mobile/certs/certificate.pem`.
### Dozzle — agents distants
```mermaid
graph LR
DOZZLE["📋 Dozzle\nuber-stup.club"] -->|":7007"| A1["VPS Production\n5.181.0.112"]
DOZZLE -->|":7007"| A2["VPS Pre-prod\n185.234.9.102"]
```
---
## CI/CD
### Pipeline backend (pre-prod & main)
```mermaid
flowchart TD
PUSH["push backend/**"] --> LINT["lint\ngolangci-lint"]
LINT --> BUILD["build\ngo build ./..."]
BUILD --> ARTIFACT["artifact\nbackend-binary 7j"]
BUILD --> DOCKER{"push only?"}
DOCKER -->|oui| D1["docker build runtime\n→ xor1234/backend-mln:latest"]
DOCKER -->|oui| D2["docker build waf\n→ xor1234/backend-mln:waf"]
D1 --> DEPLOY["SSH deploy\ndocker compose pull backend waf\ndocker compose up -d --no-deps backend waf"]
D2 --> DEPLOY
style DOCKER fill:#f0f0f0
```
### Pipeline frontend web (pre-prod & main)
```mermaid
flowchart TD
PUSH["push frontend-prep/**\nou docker/frontend/**"] --> LINT["lint-typecheck\ntsc + eslint"]
LINT --> BUILD["build\nnpm run build"]
BUILD --> ARTIFACT["artifact\nfrontend-web-dist 7j"]
BUILD --> DOCKER{"push only?"}
DOCKER -->|oui| D1["docker build\n→ xor1234/frontend-mln:latest"]
D1 --> DEPLOY["SSH deploy\ndocker compose pull frontend\ndocker compose up -d --no-deps frontend"]
style DOCKER fill:#f0f0f0
```
### Pipeline mobile (main uniquement)
```mermaid
flowchart TD
PUSH_A["push frontend-admin/**"] --> TC_A["typecheck\ntsc --noEmit"]
TC_A --> EAS_A["EAS build android\n--profile production"]
EAS_A --> APK_A["artifact\nadmin-panel-android-prod-apk 14j"]
PUSH_C["push mobile/**"] --> TC_C["typecheck\ntsc --noEmit"]
TC_C --> EAS_C["EAS build android\n--profile production"]
EAS_C --> APK_C["artifact\nclient-android-prod-apk 14j"]
```
### Secrets GitHub requis
| Secret | Branche | Usage |
|---|---|---|
| `DOCKERHUB_USERNAME` | main + pre-prod | Login Docker Hub |
| `DOCKERHUB_TOKEN` | main + pre-prod | Token Docker Hub |
| `SERVER_HOST` | pre-prod | IP/hostname VPS pre-prod |
| `SERVER_HOST_PROD` | main | IP/hostname VPS production |
| `SERVER_USER` | main + pre-prod | Utilisateur SSH |
| `SERVER_SSH_KEY` | pre-prod | Clé privée SSH ED25519 pre-prod |
| `SERVER_SSH_KEY_PROD` | main | Clé privée SSH ED25519 prod |
| `COMPOSE_PATH` | main + pre-prod | Chemin absolu docker-compose-prod.yml |
| `EXPO_TOKEN` | main | Token Expo EAS |
| `EXPO_PROJECT_ID` | main | ID projet EAS admin |
| `EXPO_PROJECT_ID_CLIENT` | main | ID projet EAS client |
| `VITE_TOMTOM_API_KEY` | main + pre-prod | Clé TomTom pour build frontend |
---
## Backend — architecture interne
```mermaid
graph TD
MAIN["main.go\ninit DB · Redis · services · Gin"] --> ROUTES["routes/routes.go\npublic · client · admin · cabine · livreur"]
ROUTES --> HANDLERS["handlers/\nauth · commands · delivery\npanier · notifications · payments"]
HANDLERS --> MODELS["models/\nstructs GORM"]
HANDLERS --> DB["db/\nconnexion · migrations · queries"]
HANDLERS --> SERVICES["services/\nTomTom · Telegram · NowPayments"]
MAIN --> WORKERS["workers/\ncron_auto_assign 5min\npayment_checker 2min\nqueue_cleanup 5min"]
MAIN --> MW["middleware/\nsession · block · clock"]
```
### Rôles utilisateurs
| Rôle | Accès |
|---|---|
| `client` | Panier, commandes, profil, suivi |
| `admin` | Gestion complète (commandes, produits, livreurs, stats) |
| `cabine` | Mise à jour statut commandes + notification client |
| `livreur` | Tableau de bord livraisons, GPS, statut |
+6
View File
@@ -0,0 +1,6 @@
img.jpg
video.mp4
s.sh
.env
uploads/
openapi.yaml
+31
View File
@@ -0,0 +1,31 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- misspell
- bodyclose
- noctx
linters-settings:
gosec:
excludes:
- G104 # erreurs non vérifiées (couvertes par errcheck)
gocritic:
disabled-checks:
- appendAssign
- sloppyReassign
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
max-issues-per-linter: 50
max-same-issues: 5
+47
View File
@@ -0,0 +1,47 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) CheckAddress(addressByUser *models.Command) error {
var correction models.Address
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
if result.Error != nil {
if isNotFound(result.Error) {
return nil
}
return fmt.Errorf("checkAddress: %w", result.Error)
}
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress)
}
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
address := models.Address{
InvalidAddress: InvalidAddressByAdmin,
CorrectAddress: CorrectAddressByAdmin,
}
if err := d.GDB.Create(&address).Error; err != nil {
return fmt.Errorf("addAddress: %w", err)
}
return nil
}
func (d *Database) DeleteAddress(InvalidAddressByAdmin string, CorrectAddressByAdmin string) error {
result := d.GDB.Where("invalid_address = ? AND correct_address = ?", InvalidAddressByAdmin, CorrectAddressByAdmin).
Delete(&models.Address{})
if result.Error != nil {
return fmt.Errorf("deleteAddress: %w", result.Error)
}
return nil
}
func (d *Database) AllAddress() ([]models.Address, error) {
var addresses []models.Address
if err := d.GDB.Find(&addresses).Error; err != nil {
return nil, fmt.Errorf("getAllAddress: %w", err)
}
return addresses, nil
}
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
alert := models.AlertPolicy{
Username: username,
Status: "true",
Message: message,
}
if err := d.GDB.Create(&alert).Error; err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
var alert models.AlertPolicy
if err := d.GDB.First(&alert, id).Error; err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
func (d *Database) DeleteAlertPolicy(id int) error {
return d.GDB.Delete(&models.AlertPolicy{}, id).Error
}
func (d *Database) EndAlert(id int) error {
result := d.GDB.Model(&models.AlertPolicy{}).
Where("id = ? AND status = 'true'", id).
Update("status", "false")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà terminée")
}
return nil
}
func (d *Database) ActivateAlert(id int) error {
result := d.GDB.Model(&models.AlertPolicy{}).
Where("id = ? AND status = 'false'", id).
Update("status", "true")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà active")
}
return nil
}
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
+374
View File
@@ -0,0 +1,374 @@
package db
import (
"fmt"
"gestion/models"
"log"
"time"
"gorm.io/gorm"
)
// AddProductInBasket ajoute un produit au panier de l'utilisateur
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) {
var productResult struct {
ID int `gorm:"column:id"`
}
err := d.GDB.Raw(`SELECT id FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
nameProduct, category).Scan(&productResult).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche du produit: %w", err)
}
if productResult.ID == 0 {
return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category)
}
productID := productResult.ID
price, err := d.GetProductPrice(nameProduct, category, quantity)
if err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
}
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
username, productID).Scan(&existing)
var basket models.Panier
if existing.ID != 0 {
newQuantity := existing.Quantity + quantity
newPrice := existing.Price + price
err = d.GDB.Raw(`
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
newQuantity, newPrice, existing.ID).Scan(&basket).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err)
}
} else {
err = d.GDB.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, created_at`,
username, productID, quantity, price).Scan(&basket).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err)
}
}
return &basket, nil
}
// GetProductPriceByID récupère le prix d'un produit par son ID et quantité
func (d *Database) GetProductPriceByID(productID int, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
}
err := d.GDB.Raw(`
SELECT price FROM product_prices
WHERE product_id = ? AND quantity = ROUND(?::NUMERIC, 3)
LIMIT 1`, productID, quantity).Scan(&result).Error
if err == nil && result.Price > 0 {
return result.Price, nil
}
err = d.GDB.Raw(`
SELECT price FROM product_prices
WHERE product_id = ? AND quantity <= ROUND(?::NUMERIC, 3)
ORDER BY quantity DESC LIMIT 1`, productID, quantity).Scan(&result).Error
if err != nil || result.Price == 0 {
return 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f", productID, quantity)
}
return result.Price, nil
}
// GetProductStockByID récupère le stock d'un produit par son ID
func (d *Database) GetProductStockByID(productID int) (float64, error) {
var result struct {
Stock float64 `gorm:"column:stock"`
}
err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("produit %d non trouvé: %w", productID, err)
}
return result.Stock, nil
}
// AddProductInBasketByID ajoute un produit au panier en utilisant son ID directement
func (d *Database) AddProductInBasketByID(username string, productID int, quantity float64) (*models.Panier, error) {
price, err := d.GetProductPriceByID(productID, quantity)
if err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
}
d.GDB.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ?`,
username, productID).Scan(&existing)
var basket models.Panier
if existing.ID != 0 {
newQuantity := existing.Quantity + quantity
newPrice := existing.Price + price
err = d.GDB.Raw(`
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? RETURNING id, username, product_id, quantity, price, created_at`,
newQuantity, newPrice, existing.ID).Scan(&basket).Error
} else {
err = d.GDB.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, created_at`,
username, productID, quantity, price).Scan(&basket).Error
}
if err != nil {
return nil, fmt.Errorf("erreur panier: %w", err)
}
return &basket, nil
}
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
}
err := d.GDB.Raw(`
SELECT price
FROM product_prices pp
INNER JOIN products p ON pp.product_id = p.id
WHERE LOWER(p.name) = LOWER(?)
AND LOWER(p.category) = LOWER(?)
AND pp.quantity <= ?
ORDER BY pp.quantity DESC
LIMIT 1`, name, category, quantity).Scan(&result).Error
if err != nil || result.Price == 0 {
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
}
return result.Price, nil
}
func (d *Database) GetProductStock(name, category string) (float64, error) {
var result struct {
Stock float64 `gorm:"column:stock"`
}
err := d.GDB.Raw(`SELECT stock FROM products WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?)`,
name, category).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("produit non trouvé: %w", err)
}
return result.Stock, nil
}
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
result := d.GDB.Exec(`
UPDATE products SET stock = stock - ?
WHERE LOWER(name) = LOWER(?) AND LOWER(category) = LOWER(?) AND stock >= ?`,
quantity, name, category, quantity)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
}
if result.RowsAffected == 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) {
var baskets []models.Panier
err := d.GDB.Raw(`
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at,
p.name as product_name, p.category, p.description
FROM baskets b
INNER JOIN products p ON b.product_id = p.id
WHERE b.username = ?
ORDER BY b.created_at DESC`, username).Scan(&baskets).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
}
return baskets, nil
}
// DecrementProductStockByID décrémente le stock d'un produit par son ID
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
result := d.GDB.Exec(`
UPDATE products SET stock = stock - ?
WHERE id = ? AND stock >= ?`, quantity, productID, quantity)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
}
return nil
}
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock.
func (d *Database) DeleteProductFromBasket(basketID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var item struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
}
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil {
return fmt.Errorf("produit non trouvé dans le panier")
}
if item.ProductID == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`,
item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur restitution stock: %w", err)
}
result := tx.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
return nil
})
}
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks.
func (d *Database) ClearBasket(username string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE products p
SET stock = stock + b.quantity
FROM baskets b
WHERE b.username = ? AND b.product_id = p.id`, username).Error; err != nil {
return fmt.Errorf("erreur restitution stock: %w", err)
}
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
}
return nil
})
}
// ClearBasketOnCheckout vide le panier après commande validée SANS restituer le stock.
func (d *Database) ClearBasketOnCheckout(username string) error {
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
}
// GetBasketTotal calcule le montant total du panier d'un utilisateur
func (d *Database) GetBasketTotal(username string) (float64, error) {
var result struct {
Total float64 `gorm:"column:total"`
}
err := d.GDB.Raw(`SELECT COALESCE(SUM(price), 0) as total FROM baskets WHERE username = ?`,
username).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
}
return result.Total, nil
}
// GetBasketItemCount compte le nombre d'items dans le panier
func (d *Database) GetBasketItemCount(username string) (int, error) {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM baskets WHERE username = ?`,
username).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
}
return result.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")
}
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
quantity, basketID)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
}
if result.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 {
var items []struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
}
if err := d.GDB.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ?`, username).Scan(&items).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
for _, item := range items {
var stockResult struct {
Stock float64 `gorm:"column:stock"`
}
if err := d.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, item.ProductID).Scan(&stockResult).Error; err != nil {
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
}
if stockResult.Stock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %g, disponible: %g)",
item.ProductID, item.Quantity, stockResult.Stock)
}
}
newReservation := time.Now().Add(15 * time.Minute)
if err := d.GDB.Exec(`UPDATE baskets SET reserved_until = ? WHERE username = ?`,
newReservation, username).Error; 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) {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Raw(`
SELECT COUNT(*) as count FROM baskets
WHERE username = ? AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`,
username).Scan(&result).Error
if err != nil {
return false, err
}
return result.Count > 0, nil
}
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
if err != nil {
return "", err
}
if username == "" {
return "", fmt.Errorf("article non trouvé")
}
return username, nil
}
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
var items []map[string]any
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price FROM baskets WHERE username = ?`,
username).Scan(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+318
View File
@@ -0,0 +1,318 @@
// ============================================
// db/cancel_commands_db.go
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
// ============================================
package db
import (
"fmt"
"log"
"slices"
"gorm.io/gorm"
)
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
if cmdResult.Username != username {
return fmt.Errorf("commande ne vous appartient pas")
}
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
return fmt.Errorf("impossible d'annuler")
}
isLateCancel := false
if cmdResult.LivreurAssign != "" {
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
} else if d.CheckCommandETAExistsAndValid(commandID) {
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", cmdResult.Status)
}
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
}
if isLateCancel && !force {
return fmt.Errorf("confirmation requise")
}
cancelMsg := reason
if cancelMsg == "Annulation par le client" {
cancelMsg = ""
}
result := tx.Exec(`
UPDATE commandes
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée")
}
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
if err := tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
} else {
log.Printf("✅ [CancelAtomic] Stock remboursé")
}
if err := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
}
if isLateCancel {
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
penalty, _ = d.CalculateCancellationPenalty(username)
if err := tx.Exec(`
UPDATE clients
SET amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username).Error; err != nil {
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
} else {
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
commandID,
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
username,
).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
}
// Nettoyage async après commit
livreur := cmdResult.LivreurAssign
if livreur != "" {
go func() {
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
}
}()
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", username),
fmt.Sprintf("client:%s:commands", username),
)
}()
return nil
})
if err != nil {
return 0, err
}
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
return penalty, nil
}
// 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)
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
if err != nil {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false
}
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
}
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
}
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
if err := tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else {
log.Printf("✅ [DeleteAtomic] Stock remboursé")
}
tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "deleted",
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
deletedBy)
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
return err
}
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
livreur := cmdResult.LivreurAssign
cmdUsername := cmdResult.Username
if livreur != "" {
go d.RemoveCommandFromAllQueues(commandID, livreur)
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", cmdUsername),
fmt.Sprintf("client:%s:commands", cmdUsername),
)
}()
return nil
})
}
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]any, error) {
query := `
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
FROM commandes
WHERE status = 'cancelled'`
args := []any{}
if username != "" {
query += " AND username = ?"
args = append(args, username)
}
query += " ORDER BY updated_at DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur récupération: %w", err)
}
return commands, nil
}
// 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)
if points <= 0 {
return fmt.Errorf("points invalides: %d", points)
}
if username == "" {
return fmt.Errorf("username vide")
}
result := d.GDB.Exec(`
UPDATE clients
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, points, username)
if result.Error != nil {
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé: %s", username)
}
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package db
import (
"fmt"
"regexp"
"time"
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
type Category struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"column:name"`
Color string `json:"color" gorm:"column:color"`
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func (Category) TableName() string { return "categories" }
func ValidateCategoryColor(color string) error {
if color == "" {
return nil
}
if !hexColorRegex.MatchString(color) {
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
}
return nil
}
func (d *Database) GetAllCategories() ([]Category, error) {
var categories []Category
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil {
return nil, err
}
if categories == nil {
categories = []Category{}
}
return categories, nil
}
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon}
if err := d.GDB.Create(&c).Error; err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
if err := d.GDB.First(&c, id).Error; err != nil {
return nil, err
}
if err := d.GDB.Model(&c).Updates(map[string]interface{}{"name": name, "color": color, "is_coming_soon": isComingSoon}).Error; err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) DeleteCategory(id int) error {
var count int64
d.GDB.Table("products").
Where("category = (SELECT name FROM categories WHERE id = ?)", id).
Count(&count)
if count > 0 {
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
}
result := d.GDB.Delete(&Category{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("catégorie non trouvée")
}
return nil
}
func (d *Database) CategoryExists(name string) (bool, error) {
var count int64
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
return count > 0, err
}
+736
View File
@@ -0,0 +1,736 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strings"
"time"
"gorm.io/gorm"
)
func (d *Database) CreateClient(client *models.Client) error {
var result struct {
ID int `gorm:"column:id"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
INSERT INTO clients (username, password, nom, prenom, telephone, command, amende, must_change_password, created_at)
VALUES (?, ?, ?, ?, ?, 0, 0.0, ?, CURRENT_TIMESTAMP)
RETURNING id, created_at`,
client.Username, client.Password, client.Nom, client.Prenom, client.Telephone, client.MustChangePassword,
).Scan(&result).Error
if err != nil {
return fmt.Errorf("erreur lors de la création du client: %w", err)
}
client.ID = result.ID
client.CreatedAt = result.CreatedAt
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 row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende,
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
FROM clients WHERE id = ?`, id).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, fmt.Errorf("client non trouvé")
}
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
CreatedAt: row.CreatedAt,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
return client, nil
}
// GetAllClients récupère tous les clients
func (d *Database) GetAllClients() ([]*models.Client, error) {
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
ReferralBalance float64 `gorm:"column:referral_balance"`
CancellationsCount int `gorm:"column:cancellations_count"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, referral_balance,
COALESCE(cancellations_count, 0) as cancellations_count,
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
FROM clients ORDER BY created_at DESC`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
}
clients := make([]*models.Client, 0, len(rows))
for _, row := range rows {
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
ReferralBalance: row.ReferralBalance,
CancellationsCount: row.CancellationsCount,
CreatedAt: row.CreatedAt,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
clients = append(clients, client)
}
return clients, nil
}
// UpdateClient met à jour un client existant
func (d *Database) UpdateClient(client *models.Client) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", client.ID).Updates(map[string]any{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"amende": client.Amende,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du client: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// DeleteClient supprime un client
func (d *Database) DeleteClient(id int) error {
_ = d.RevokeAllUserTokens(id, "client")
result := d.GDB.Delete(&models.Client{}, id)
if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression du client: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UpdateClientPassword met à jour le mot de passe d'un client
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("password", hashedPassword)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false
func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Updates(map[string]any{
"password": hashedPassword,
"must_change_password": false,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
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
}
var statsResult struct {
Total int `gorm:"column:total"`
Pending int `gorm:"column:pending"`
Completed int `gorm:"column:completed"`
}
if err := d.GDB.Raw(`
SELECT
COUNT(*) as total,
COALESCE(SUM(CASE WHEN status = 'pending' OR status = 'livre' THEN 1 ELSE 0 END), 0) as pending,
COALESCE(SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END), 0) as completed
FROM commandes WHERE username = ?`, client.Username).Scan(&statsResult).Error; err != nil {
log.Printf("⚠️ Erreur calcul stats: %v", err)
}
stats := map[string]interface{}{
"id": clientID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"total_commands": statsResult.Total,
"pending_commands": statsResult.Pending,
"completed_commands": statsResult.Completed,
"points_extra": client.PointsExtra,
"amende": client.Amende,
"member_since": client.CreatedAt,
}
return stats, nil
}
func (d *Database) GetClientAmende(username string) (float64, error) {
var result struct {
Amende float64 `gorm:"column:amende"`
}
err := d.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`, username).Scan(&result).Error
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, result.Amende)
return result.Amende, nil
}
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
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)
}
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", 0.0)
if result.Error != nil {
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur paiement pénalités: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
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 {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
if result.Error != nil {
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) AddClientPointsByCategory(username string, points int, poolKey string) error {
if poolKey == "" {
poolKey = "pool_0"
}
result := d.GDB.Exec(`
UPDATE clients
SET points_extra = jsonb_set(
COALESCE(points_extra, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, points, username)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'ajout de points: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ %d points (key=%s) ajoutés au client %s", points, poolKey, username)
return nil
}
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
var totalPoints int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
return err
}
totalPoints = points
return nil
})
if err != nil {
return 0, err
}
return totalPoints, nil
}
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, created_at
FROM clients WHERE telephone = ?`, telephone).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, nil
}
return &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
CreatedAt: row.CreatedAt,
}, nil
}
// GetClientByUsername récupère un client par son username
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
MustChangePassword bool `gorm:"column:must_change_password"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende,
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
two_fa_enabled
FROM clients WHERE username = ?`, username).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, nil
}
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
MustChangePassword: row.MustChangePassword,
CreatedAt: row.CreatedAt,
TwoFAEnabled: row.TwoFAEnabled,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
return client, nil
}
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
}
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
amende, err := d.GetClientAmende(username)
if err != nil {
return nil, err
}
cancellationsCount, err := d.GetClientCancellationsCount(username)
if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
cancellationsCount = 0
}
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
}
// ResetClientPoint réinitialise les points d'un client.
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey string) error {
if extraPoolKey != "" {
err := d.GDB.Exec(`
UPDATE clients SET points_extra = points_extra - ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, extraPoolKey, username).Error
if err != nil {
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE extra: %v", err)
} else {
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
}
return err
}
// -1 ou poolIdx sans clé → reset total
result := d.GDB.Exec(`
UPDATE clients SET points_extra = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username)
if result.Error != nil {
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset points: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return 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 = ?`
} else {
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
}
result := d.GDB.Exec(query, username)
if result.Error != nil {
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
var rows []struct {
Username string `gorm:"column:username"`
Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"`
UpdatedAt interface{} `gorm:"column:updated_at"`
}
err := d.GDB.Raw(`
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
FROM clients
WHERE amende > 0
ORDER BY amende DESC`).Scan(&rows).Error
if err != nil {
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération clients: %w", err)
}
clients := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
clients = append(clients, map[string]interface{}{
"username": row.Username,
"total_penalty": row.Amende,
"cancellations_count": row.CancellationsCount,
"last_updated": row.UpdatedAt,
})
}
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
return clients, nil
}
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
var result struct {
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
TotalPenalties float64 `gorm:"column:total_penalties"`
AvgPenalty float64 `gorm:"column:avg_penalty"`
MaxPenalty float64 `gorm:"column:max_penalty"`
TotalClients int `gorm:"column:total_clients"`
}
err := d.GDB.Raw(`
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`).Scan(&result).Error
if err != nil {
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
stats := map[string]interface{}{
"clients_with_penalties": result.ClientsWithPenalties,
"total_penalties": result.TotalPenalties,
"average_penalty": result.AvgPenalty,
"max_penalty": result.MaxPenalty,
"total_clients": result.TotalClients,
}
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
result.ClientsWithPenalties, result.TotalClients)
return stats, nil
}
func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int, username string) (int, string, error) {
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
// Charger les paramètres globaux
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
settings = DefaultSettings()
}
pools := settings.PointsPools
if len(pools) == 0 {
log.Printf("️ [CalcPointsTx] Aucun pool configuré → 0 points")
return 0, "", nil
}
// Construire la map catégorie → index de pool
catToPool := make(map[string]int)
for i, pool := range pools {
for _, cat := range pool.Categories {
catToPool[strings.ToLower(cat)] = i
}
}
if len(catToPool) == 0 {
log.Printf("️ [CalcPointsTx] Aucune catégorie assignée aux pools → 0 points")
return 0, "", nil
}
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
var items []struct {
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
Category string `gorm:"column:category"`
}
if err := tx.Raw(`
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
FROM command_items ci
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = ?
`, commandID).Scan(&items).Error; err != nil {
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
}
if len(items) == 0 {
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
return 0, "", nil
}
poolTotals := make([]float64, len(pools))
for _, item := range items {
catLower := strings.ToLower(item.Category)
if poolIdx, ok := catToPool[catLower]; ok {
poolTotals[poolIdx] += item.Prix
}
}
for i, t := range poolTotals {
log.Printf("📊 [CalcPointsTx] Pool[%d] (%s): %.2f€", i, pools[i].Name, t)
}
// ✅ ÉTAPE 2: Calculer les points pour tous les pools
var totalPoints int
var pointCategory string
poolPts := make([]int, len(pools))
var categoryParts []string
for i, pool := range pools {
poolPts[i] = CalcPointsFromTiers(poolTotals[i], pool.Tiers)
totalPoints += poolPts[i]
if poolPts[i] > 0 {
categoryParts = append(categoryParts, pool.Name)
}
}
log.Printf("💰 [CalcPointsTx] points par pool: %v, total=%d", poolPts, totalPoints)
if totalPoints == 0 {
return 0, "", nil
}
if len(categoryParts) > 0 {
pointCategory = strings.Join(categoryParts, " & ")
} else {
pointCategory = "points"
}
for i, pool := range pools {
if poolPts[i] == 0 {
continue
}
if err := tx.Exec(`
UPDATE clients
SET points_extra = jsonb_set(
COALESCE(points_extra, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, pool.Key, pool.Key, poolPts[i], username).Error; err != nil {
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points_extra pool[%d] (%s): %v", i, pool.Key, err)
return 0, "", fmt.Errorf("erreur mise à jour points pool[%d]: %w", i, err)
}
log.Printf("💰 [CalcPointsTx] pool[%d] (%s / key=%s): +%d pts", i, pool.Name, pool.Key, poolPts[i])
}
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
return totalPoints, pointCategory, 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.GDB.Raw(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = ? AND livreur_assign = ?
)
`, commandID, username).Scan(&exists).Error
return exists, err
}
// 👤 User : seulement SES commandes
err := d.GDB.Raw(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = ? AND username = ?
)
`, commandID, username).Scan(&exists).Error
return exists, err
}
+476
View File
@@ -0,0 +1,476 @@
package db
import (
"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 float64) 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 int,
quantite float64,
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
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; 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
err := d.GDB.Exec(`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
commandID, produit, productID, quantite, prix,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
).Error
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
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress *string `gorm:"column:delivery_address"`
Status *string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
ClientOrderNumber int `gorm:"column:client_order_number"`
}
err := d.GDB.Raw(`
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.referral_used,
c.livreur_assign,
c.created_at as command_created_at,
p.category,
c.client_order_id as client_order_number
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 = ?
ORDER BY ci.id ASC`, commandID).Scan(&rows).Error
if err != nil {
log.Printf("❌ Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
}
items := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt interface{}
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]interface{}{
"id": row.ID,
"command_id": row.CommandID,
"produit": row.Produit,
"product_id": productIDValue,
"quantite": row.Quantite,
"prix": row.Prix,
"client_username": row.ClientUsername,
"client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom,
"client_telephone": row.ClientTelephone,
"delivery_address": ptrStr(row.DeliveryAddress),
"status": ptrStr(row.Status),
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
// Infos commande
"command_status": ptrStr(row.CommandStatus),
"command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix,
"referral_used": row.ReferralUsed,
"livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt,
"category": row.Category,
"client_order_number": row.ClientOrderNumber,
}
items = append(items, item)
}
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
return items, nil
}
// ptrStr retourne la valeur d'un *string ou "" si nil
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]interface{}, error) {
if err := validateUsername(username); err != nil {
log.Printf("❌ [GetCommandItemsByUsername] %v", err)
return nil, err
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress *string `gorm:"column:delivery_address"`
Status *string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
}
err := d.GDB.Raw(`
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 = ?
ORDER BY ci.command_id DESC, ci.id ASC`, username).Scan(&rows).Error
if err != nil {
log.Printf("❌ Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
}
items := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt interface{}
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]interface{}{
"id": row.ID,
"command_id": row.CommandID,
"produit": row.Produit,
"product_id": productIDValue,
"quantite": row.Quantite,
"prix": row.Prix,
"client_username": row.ClientUsername,
"client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom,
"client_telephone": row.ClientTelephone,
"delivery_address": ptrStr(row.DeliveryAddress),
"status": ptrStr(row.Status),
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
"command_status": ptrStr(row.CommandStatus),
"command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix,
"livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt,
}
items = append(items, item)
}
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
return items, nil
}
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
if err := validateCommandID(commandID); err != nil {
return err
}
if err := validateItemID(itemID); err != nil {
return err
}
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
var result struct {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
}
if err := d.GDB.Raw(`SELECT prix, quantite FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
// Supprimer l'item
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
// Recalculer le total de la commande
if err := d.GDB.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err)
}
return nil
}
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
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
}
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; 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)
}
result := d.GDB.Exec(`
UPDATE command_items
SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, status, itemID)
if result.Error != nil {
log.Printf("❌ Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ Item %d non trouvé", itemID)
return fmt.Errorf("item non trouvé")
}
return nil
}
+176
View File
@@ -0,0 +1,176 @@
// ============================================
// db/commands_priority.go
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
// ============================================
package db
import (
"fmt"
"gestion/models"
"log"
"slices"
)
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]any, 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 := []any{}
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
if !slices.Contains(validStatuses, status) {
return nil, fmt.Errorf("statut invalide: %s", status)
}
query += " AND c.status = ?"
args = append(args, status)
}
if username != "" {
query += " AND c.username = ?"
args = append(args, username)
}
query += " ORDER BY c.created_at ASC"
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
}
return commands, nil
}
// GetOldestPendingCommand récupère la commande pending la plus ancienne
func (d *Database) GetOldestPendingCommand() (map[string]any, error) {
var commands []map[string]any
err := d.GDB.Raw(`
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`).Scan(&commands).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
}
if len(commands) == 0 {
return nil, nil
}
return commands[0], nil
}
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
Adresse string `gorm:"column:adresse"`
TotalPrix float64 `gorm:"column:total_prix"`
CreatedAt string `gorm:"column:created_at"`
UpdatedAt string `gorm:"column:updated_at"`
WaitingSeconds float64 `gorm:"column:waiting_seconds"`
}
err := d.GDB.Raw(`
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`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
}
commands := make([]*models.CommandPriority, 0, len(rows))
for _, row := range rows {
cmd := &models.CommandPriority{
ID: row.ID,
Username: row.Username,
Status: row.Status,
Address: row.Adresse,
TotalPrice: row.TotalPrix,
WaitingSeconds: int(row.WaitingSeconds),
WaitingMinutes: int(row.WaitingSeconds / 60),
}
commands = append(commands, cmd)
}
return commands, nil
}
// GetCommandWaitingTime récupère le temps d'attente d'une commande
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
var result struct {
WaitingSeconds int `gorm:"column:waiting_seconds"`
}
err := d.GDB.Raw(`
SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
FROM commandes WHERE id = ?`, commandID).Scan(&result).Error
if err != nil {
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
}
if result.WaitingSeconds == 0 {
// Vérifie si la commande existe vraiment
var exists bool
d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists)
if !exists {
return 0, fmt.Errorf("commande non trouvée")
}
}
return result.WaitingSeconds, nil
}
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
var result struct {
TotalPending int `gorm:"column:total_pending"`
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
OldestCommandDate *string `gorm:"column:oldest_command_date"`
NewestCommandDate *string `gorm:"column:newest_command_date"`
}
err := d.GDB.Raw(`
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'`).Scan(&result).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
stats := map[string]any{
"total_pending": result.TotalPending,
"avg_waiting_seconds": 0,
"avg_waiting_minutes": 0,
"oldest_command_date": nil,
"newest_command_date": nil,
"oldest_waiting_minutes": 0,
}
if result.AvgWaitingSeconds != nil {
stats["avg_waiting_seconds"] = int(*result.AvgWaitingSeconds)
stats["avg_waiting_minutes"] = int(*result.AvgWaitingSeconds / 60)
}
if result.OldestCommandDate != nil {
stats["oldest_command_date"] = *result.OldestCommandDate
}
if result.NewestCommandDate != nil {
stats["newest_command_date"] = *result.NewestCommandDate
}
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min",
result.TotalPending, stats["avg_waiting_minutes"])
return stats, nil
}
+927
View File
@@ -0,0 +1,927 @@
package db
import (
"fmt"
"gestion/models"
"log"
"slices"
"strings"
"time"
"gorm.io/gorm"
)
func sanitizeString(s string) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, s)
if len(sanitized) > 1000 {
sanitized = sanitized[:1000]
}
return strings.TrimSpace(sanitized)
}
func validateAddress(address string) error {
address = strings.TrimSpace(address)
if address == "" {
return fmt.Errorf("adresse vide non autorisée")
}
if len(address) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)")
}
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
}
return nil
}
type basketItem struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
}
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
var items []basketItem
if err := d.GDB.Table("baskets").Select("product_id, quantity, price").Where("username = ?", username).Scan(&items).Error; err != nil {
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
}
total := 0.0
for _, item := range items {
total += item.Price
}
return items, total, nil
}
// validateCommandStatus vérifie si le statut est valide
func validateCommandStatus(status string) error {
validStatuses := map[string]bool{
"pending": true,
"assigned": true,
"en_route": true,
"arrived": true,
"livre": true,
"approved": true,
"cancelled": true,
"disabled": true,
}
if !validStatuses[status] {
return fmt.Errorf("statut invalide: %s", status)
}
return nil
}
func (d *Database) CreateCommand(username string) (*models.Command, error) {
adresse := "Adresse non spécifiée"
var clientCheck models.Client
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
adresse = clientCheck.Username
}
basketItems, totalPrix, err := d.fetchBasketItems(username)
if err != nil {
return nil, err
}
if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide")
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err = d.GDB.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
}
commandID := cmdResult.ID
for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID)
if err != nil {
productName = "Produit inconnu"
}
cmdItem := models.CommandItem{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
}
if err := d.GDB.Create(&cmdItem).Error; err != nil {
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
}
}
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err)
}
command := &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Status: "pending",
Total: totalPrix,
}
return command, nil
}
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
if err := validateUsername(username); err != nil {
return nil, err
}
if err := validateAddress(deliveryAddress); err != nil {
return nil, err
}
client, err := d.GetClientByUsername(username)
if err != nil {
log.Printf("⚠️ Client non trouvé: %v", err)
}
clientNom := ""
clientPrenom := ""
clientTelephone := ""
if client != nil {
clientNom = sanitizeString(client.Nom)
clientPrenom = sanitizeString(client.Prenom)
clientTelephone = sanitizeString(client.Telephone)
}
basketItems, totalPrix, err := d.fetchBasketItems(username)
if err != nil {
log.Printf("❌ Erreur query basket: %v", err)
return nil, err
}
if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide")
}
for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return nil, fmt.Errorf("données panier invalides")
}
}
if totalPrix <= 0 || totalPrix > 100000 {
return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err = d.GDB.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error
if err != nil {
return nil, fmt.Errorf("erreur création commande: %w", err)
}
commandID := cmdResult.ID
for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID)
if err != nil || productName == "" {
productName = fmt.Sprintf("Produit #%d", item.ProductID)
}
err = d.InsertCommandItemWithClientInfo(
commandID,
productName,
item.ProductID,
item.Quantity,
item.Price,
username,
clientNom,
clientPrenom,
clientTelephone,
deliveryAddress,
)
if err != nil {
log.Printf("❌ Erreur INSERT command_items: %v", err)
return nil, fmt.Errorf("erreur insertion items: %w", err)
}
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
}
if err := d.GDB.Delete(&models.Panier{}, "username = ?", username).Error; err != nil {
log.Printf("⚠️ Erreur vidage panier: %v", err)
}
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
d.AddCommandLog(commandID, "created",
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
username)
command := &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Username: username,
Status: "pending",
Total: totalPrix,
DeliveryAddress: deliveryAddress,
CreatedAt: cmdResult.CreatedAt,
UpdatedAt: cmdResult.UpdatedAt,
}
return command, nil
}
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
var commands []models.Command
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
return commands, err
}
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
if username != "" {
if err := validateUsername(username); err != nil {
return nil, err
}
}
if status != "" {
if err := validateCommandStatus(status); err != nil {
return nil, err
}
}
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
Adresse string `gorm:"column:adresse"`
TotalPrix float64 `gorm:"column:total_prix"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ProposedAddress *string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
ClientOrderNumber int `gorm:"column:client_order_number"`
ReferralUsed float64 `gorm:"column:referral_used"`
CancelReason string `gorm:"column:cancel_reason"`
}
gdb := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at,
c.proposed_address, c.address_proposal_status,
c.client_order_id AS client_order_number,
COALESCE(c.referral_used, 0) AS referral_used,
COALESCE(c.cancel_reason, '') AS cancel_reason`)
if status == "" {
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
} else {
gdb = gdb.Where("c.status = ?", status)
}
if username != "" {
gdb = gdb.Where("c.username = ?", username)
}
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
commands := make([]map[string]any, 0, len(rows))
for _, row := range rows {
command := map[string]any{
"id": row.ID,
"username": row.Username,
"status": row.Status,
"adresse": sanitizeString(row.Adresse),
"total_prix": row.TotalPrix,
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
"address_proposal_status": row.AddressProposalStatus,
"client_order_number": row.ClientOrderNumber,
"referral_used": row.ReferralUsed,
"cancel_reason": row.CancelReason,
}
if row.LivreurAssign != nil {
command["livreur_assign"] = *row.LivreurAssign
} else {
command["livreur_assign"] = nil
}
if row.ProposedAddress != nil {
command["proposed_address"] = *row.ProposedAddress
} else {
command["proposed_address"] = nil
}
commands = append(commands, command)
}
return commands, nil
}
func (d *Database) GetCommandCount() (int, error) {
var count int64
if err := d.GDB.Model(&models.Command{}).Count(&count).Error; err != nil {
return 0, fmt.Errorf("erreur récupération count commandes: %w", err)
}
return int(count), nil
}
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
}
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
Adresse string `gorm:"column:adresse"`
TotalPrix float64 `gorm:"column:total_prix"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ProposedAddress *string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
ReferralUsed float64 `gorm:"column:referral_used"`
ClientOrderNumber int `gorm:"column:client_order_number"`
CancelReason string `gorm:"column:cancel_reason"`
}
if err := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
c.referral_used, c.client_order_id AS client_order_number,
COALESCE(c.cancel_reason, '') AS cancel_reason`).
Where("c.id = ?", id).
First(&row).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
}
if row.ID == 0 {
return nil, fmt.Errorf("commande non trouvée")
}
command := map[string]any{
"id": row.ID,
"username": row.Username,
"status": row.Status,
"adresse": row.Adresse,
"total_prix": row.TotalPrix,
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
"address_proposal_status": row.AddressProposalStatus,
"referral_used": row.ReferralUsed,
"client_order_number": row.ClientOrderNumber,
"cancel_reason": row.CancelReason,
}
if row.LivreurAssign != nil {
command["livreur_assign"] = *row.LivreurAssign
} else {
command["livreur_assign"] = nil
}
if row.ProposedAddress != nil {
command["proposed_address"] = *row.ProposedAddress
} else {
command["proposed_address"] = nil
}
return command, nil
}
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
// Retourne commandID en fallback si introuvable.
func (d *Database) GetClientOrderID(commandID int) int {
var result struct {
ClientOrderID int `gorm:"column:client_order_id"`
}
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
return commandID
}
return result.ClientOrderID
}
func (d *Database) GetCommandAddress(commandID int) (string, error) {
var result struct {
Adresse string `gorm:"column:adresse"`
}
if err := d.GDB.Model(&models.Command{}).Select("adresse").Where("id = ?", commandID).First(&result).Error; err != nil {
return "", fmt.Errorf("erreur lors de la récupération de l'adresse: %w", err)
}
if result.Adresse == "" {
return "", fmt.Errorf("commande non trouvée")
}
return result.Adresse, nil
}
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
if len(deliveryAddress) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)")
}
if strings.TrimSpace(deliveryAddress) == "" {
return fmt.Errorf("adresse vide non autorisée")
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"adresse": deliveryAddress,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ Adresse commande %d mise à jour", commandID)
return nil
}
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil {
return err
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"proposed_address": proposedAddress,
"address_proposal_status": "pending",
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
d.AddCommandLog(commandID, "address_proposed",
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
proposedBy)
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
return nil
}
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
var query string
if accepted {
query = `UPDATE commandes
SET adresse = proposed_address, proposed_address = NULL,
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
} else {
query = `UPDATE commandes
SET proposed_address = NULL, address_proposal_status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
}
result := d.GDB.Exec(query, commandID, clientUsername)
if result.Error != nil {
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("aucune proposition en attente pour cette commande")
}
action := "refusée"
if accepted {
action = "acceptée"
}
d.AddCommandLog(commandID, "address_proposal_"+action,
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
clientUsername)
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
return nil
}
// UpdateCommandStatus met à jour le statut d'une commande
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
if err := validateCommandStatus(status); err != nil {
return err
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"status": status,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
return nil
}
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
sanitizedMessage := sanitizeLogMessage(message)
sanitizedAuthor := sanitizeLogMessage(author)
if err := d.GDB.Create(&models.CommandLog{
CommandID: commandID,
Status: status,
Message: sanitizedMessage,
Author: sanitizedAuthor,
}).Error; err != nil {
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
return nil
}
return nil
}
// GetCommandLogs récupère tous les logs d'une commande
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
var rows []models.CommandLog
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
return []map[string]any{}, nil
}
logs := make([]map[string]any, 0, len(rows))
for _, row := range rows {
logs = append(logs, map[string]any{
"id": row.ID,
"command_id": row.CommandID,
"status": row.Status,
"message": row.Message,
"author": row.Author,
"created_at": row.CreatedAt,
})
}
return logs, nil
}
func sanitizeLogMessage(message string) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, message)
if len(sanitized) > 1000 {
sanitized = sanitized[:1000]
}
return sanitized
}
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
var totalPoints int
var cmdUsernameOut string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
TotalPrix float64 `gorm:"column:total_prix"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
cmd.Status, cmd.Username, cmd.LivreurAssign)
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
}
if cmd.Status == "approved" {
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
return fmt.Errorf("commande déjà approuvée")
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return fmt.Errorf("commande déjà modifiée par une autre requête")
}
if cmd.Username != "" {
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
if err != nil {
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = points
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, cmd.Username).Error; err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
} else {
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
adminUsername).Error; err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
}
cmdUsernameOut = cmd.Username
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
return 0, err
}
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
commandID, totalPoints)
if livreurAssignOut != "" {
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
}
}()
}
go func() {
commandCacheKey := fmt.Sprintf("command:%d", commandID)
Redis.Del(RedisCtx, commandCacheKey)
if cmdUsernameOut != "" {
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
Redis.Del(RedisCtx, clientCacheKey)
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
Redis.Del(RedisCtx, clientCommandsCacheKey)
}
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
}()
return totalPoints, nil
}
// ApproveDeliveryAtomic - Version atomique pour approbation client
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
var totalPoints int
var pointCategory string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
if cmd.Username != username {
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
username, cmd.Username)
return fmt.Errorf("cette commande ne vous appartient pas")
}
if cmd.Status != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
if result.Error != nil {
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return fmt.Errorf("commande déjà approuvée ou modifiée")
}
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = pts
pointCategory = cat
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
}
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
return 0, "", err
}
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
commandID, totalPoints, pointCategory)
if livreurAssignOut != "" {
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
}
}()
}
go func() {
commandCacheKey := fmt.Sprintf("command:%d", commandID)
Redis.Del(RedisCtx, commandCacheKey)
clientCacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, clientCacheKey)
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
Redis.Del(RedisCtx, clientCommandsCacheKey)
log.Printf("✅ [ApproveAtomic] Caches invalidés")
}()
return totalPoints, pointCategory, nil
}
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
var totalPoints int
var pointCategory string
var clientUsernameOut string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
return fmt.Errorf("commande non trouvée")
}
if cmd.Status != "livre" {
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre'`, commandID)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà approuvée ou modifiée")
}
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
if err != nil {
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = pts
pointCategory = cat
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, cmd.Username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
staffUsername).Error; err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
}
clientUsernameOut = cmd.Username
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
return 0, "", "", err
}
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
commandID, staffUsername, totalPoints, clientUsernameOut)
if livreurAssignOut != "" {
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
}
}()
}
go func() {
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
}()
return totalPoints, pointCategory, clientUsernameOut, nil
}
+197
View File
@@ -0,0 +1,197 @@
package db
import (
"fmt"
"log"
"slices"
"gorm.io/gorm"
)
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]any, error) {
var livreurs []map[string]any
err := d.GDB.Raw(`
SELECT id, username, total, livraison
FROM users
WHERE role = 'livreur'
ORDER BY username`).Scan(&livreurs).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
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)
return d.GDB.Transaction(func(tx *gorm.DB) error {
var roleResult struct {
Role string `gorm:"column:role"`
}
err := tx.Raw(`SELECT role FROM users WHERE username = ? FOR UPDATE`, livreurUsername).Scan(&roleResult).Error
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 roleResult.Role == "" {
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
return fmt.Errorf("livreur non trouvé")
}
if roleResult.Role != "livreur" {
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, roleResult.Role)
return fmt.Errorf("l'utilisateur n'est pas un livreur")
}
var cmdResult struct {
Status string `gorm:"column:status"`
LivreurAssign *string `gorm:"column:livreur_assign"`
}
err = tx.Raw(`SELECT status, livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
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)
}
if cmdResult.Status == "" {
log.Printf("❌ Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
validStatusesForAssignment := []string{"pending", "assigned"}
if !slices.Contains(validStatusesForAssignment, cmdResult.Status) {
log.Printf("❌ Statut invalide pour assignation: %s", cmdResult.Status)
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", cmdResult.Status)
}
result := tx.Exec(`
UPDATE commandes
SET livreur_assign = ?,
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND status IN ('pending', 'assigned')`, livreurUsername, commandID)
if result.Error != nil {
log.Printf("❌ Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", result.Error)
}
if result.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é)")
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "assigned",
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
"admin",
).Error; err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
// Non bloquant
}
return nil
})
}
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
FROM commandes
WHERE livreur_assign = ?`
args := []any{livreurUsername}
if status != "" {
query += " AND status = ?"
args = append(args, status)
}
query += " ORDER BY created_at DESC"
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
return commands, nil
}
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
result := d.GDB.Exec(`
UPDATE users
SET livraison = livraison + 1,
total = total + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ? AND role = 'livreur'`, livreurUsername)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("livreur non trouvé")
}
return nil
}
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
LivreurAssign *string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT username, status, livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
if cmdResult.Username != clientUsername {
return fmt.Errorf("cette commande ne vous appartient pas")
}
if cmdResult.Status != "livre" {
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", cmdResult.Status)
}
result := tx.Exec(`
UPDATE commandes
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, clientUsername)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
}
if cmdResult.LivreurAssign != nil && *cmdResult.LivreurAssign != "" {
res := tx.Exec(`
UPDATE users
SET livraison = livraison + 1,
total = total + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ? AND role = 'livreur'`, *cmdResult.LivreurAssign)
if res.Error != nil {
log.Printf("⚠️ Erreur incrémentation livreur: %v", res.Error)
} else if res.RowsAffected > 0 {
log.Printf(" ✅ Compteur livreur incrémenté: %s", *cmdResult.LivreurAssign)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
clientUsername,
).Error; err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
}
return nil
})
}
+46
View File
@@ -0,0 +1,46 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
q := d.GDB.Order("created_at DESC")
if status != "" {
q = q.Where("status = ?", status)
}
var issues []models.DeliveryIssue
if err := q.Find(&issues).Error; err != nil {
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
}
return issues, nil
}
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
issue := models.DeliveryIssue{
CommandID: commandID,
IssueType: issueType,
Description: description,
Status: "open",
ReportedBy: reportedBy,
}
if err := d.GDB.Create(&issue).Error; err != nil {
return nil, fmt.Errorf("erreur création problème: %w", err)
}
return &issue, nil
}
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
result := d.GDB.Model(&models.DeliveryIssue{}).Where("id = ?", issueID).
Updates(map[string]any{"status": status, "resolution": resolution, "resolved_by": resolvedBy})
if result.Error != nil {
return fmt.Errorf("erreur mise à jour problème: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("problème non trouvé")
}
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
return nil
}
+299
View File
@@ -0,0 +1,299 @@
// ============================================
// db/delivery_management_db.go
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
// ============================================
package db
import (
"fmt"
"log"
"strings"
"time"
)
var allowedStatuses = map[string]bool{
"available": true,
"offline": true,
"busy": true,
}
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
if statuses == "" {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ?`, livreurUsername).Scan(&result).Error
if err != nil {
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
}
return result.Count, nil
}
cleanStatuses, err := ValidateStatuses(statuses)
if err != nil {
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
return 0, err
}
var result struct {
Count int `gorm:"column:count"`
}
err = d.GDB.Raw(
`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ? AND status IN ?`,
livreurUsername, cleanStatuses,
).Scan(&result).Error
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",
result.Count, livreurUsername, cleanStatuses)
return result.Count, nil
}
func ValidateStatuses(statuses string) ([]string, error) {
if statuses == "" {
return nil, nil
}
statusList := strings.Split(statuses, ",")
validStatusMap := map[string]bool{
"pending": true,
"assigned": true,
"en_route": true,
"arrived": true,
"livre": true,
"approved": true,
"cancelled": true,
}
var cleanStatuses []string
var invalidStatuses []string
for _, status := range statusList {
status = strings.TrimSpace(status)
if status == "" {
continue
}
if validStatusMap[status] {
cleanStatuses = append(cleanStatuses, status)
} else {
invalidStatuses = append(invalidStatuses, status)
}
}
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) {
var result struct {
LastDate *time.Time `gorm:"column:last_date"`
}
err := d.GDB.Raw(`
SELECT MAX(updated_at) as last_date
FROM commandes
WHERE livreur_assign = ? AND status = 'approved'`, livreurUsername).Scan(&result).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
}
return result.LastDate, nil
}
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
if err != nil {
return 0, nil
}
var currentID int
_, err = fmt.Sscanf(currentIDStr, "%d", &currentID)
if err != nil {
return 0, nil
}
return currentID, nil
}
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]any, error) {
var history []map[string]any
err := d.GDB.Raw(`
SELECT
c.id as command_id,
c.username as client,
c.status,
c.adresse,
c.total_prix::float8 as total_prix,
c.created_at as assigned_at,
c.updated_at as completed_at
FROM commandes c
WHERE c.livreur_assign = ?
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`, livreurUsername, limit, offset).Scan(&history).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération historique: %w", err)
}
return history, nil
}
// 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 {
return "offline", nil
}
return status, nil
}
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
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)
if err := Redis.Set(RedisCtx, statusKey, status, 0).Err(); 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
}
// 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
}
// UpdateCommandLivreur met à jour le livreur assigné à une commande
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
result := d.GDB.Exec(`
UPDATE commandes
SET livreur_assign = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, livreurUsername, commandID)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour livreur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
return nil
}
// GetAllDeliveryPersonsStats récupère les stats de tous les livreurs
func (d *Database) GetAllDeliveryPersonsStats() ([]map[string]any, error) {
livreurs, err := d.GetAvailableDeliveryPersons()
if err != nil {
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
}
var stats []map[string]any
for _, livreur := range livreurs {
username := livreur["username"].(string)
totalDeliveries, _ := d.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := d.CountDeliveriesByStatus(username, "approved")
queueSize, _ := d.GetDeliverymanQueueSize(username)
status, _ := d.GetDeliveryPersonStatus(username)
stats = append(stats, map[string]any{
"username": username,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"queue_size": queueSize,
"status": status,
})
}
return stats, nil
}
// GetDeliveryPersonsByStatus récupère les livreurs par statut
func (d *Database) GetDeliveryPersonsByStatus(status string) ([]string, error) {
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
}
// 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)
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 {
if err := Redis.Del(RedisCtx, key).Err(); err != nil {
log.Printf("⚠️ [ClearDeliveryData] Erreur suppression clé %s: %v", key, err)
}
}
log.Printf("✅ [ClearDeliveryData] Données nettoyées pour %s", livreurUsername)
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package db
import (
"fmt"
"log"
)
func (d *Database) GetCommandCategories(commandID int) ([]string, error) {
var categories []string
err := d.GDB.Raw(`
SELECT DISTINCT p.category
FROM command_items ci
JOIN products p ON p.id = ci.product_id
WHERE ci.command_id = ? AND p.category IS NOT NULL AND p.category != ''`,
commandID,
).Scan(&categories).Error
if err != nil {
return nil, fmt.Errorf("erreur lecture catégories commande %d: %w", commandID, err)
}
return categories, nil
}
func (d *Database) GetEligibleDeliverymenForCommand(commandID int) ([]string, error) {
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [DELIVERY_MODE] Erreur lecture settings: %v — fallback single", err)
}
allActive := func() ([]string, error) {
livreurs, err := d.GetAllActiveDeliveryPersons()
if err != nil {
return nil, err
}
names := make([]string, len(livreurs))
for i, l := range livreurs {
names[i] = l.Username
}
return names, nil
}
if settings.DeliveryMode.Mode != "category_based" || len(settings.DeliveryMode.CategoryRoutes) == 0 {
return allActive()
}
categories, err := d.GetCommandCategories(commandID)
if err != nil || len(categories) == 0 {
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: catégories non trouvées — fallback single", commandID)
return allActive()
}
catSet := make(map[string]bool, len(categories))
for _, c := range categories {
catSet[c] = true
}
eligible := make(map[string]bool)
for _, route := range settings.DeliveryMode.CategoryRoutes {
for _, routeCat := range route.Categories {
if catSet[routeCat] {
eligible[route.DeliverymanUsername] = true
break
}
}
}
if len(eligible) == 0 {
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: aucun livreur pour catégories %v — fallback single", commandID, categories)
return allActive()
}
result := make([]string, 0, len(eligible))
for u := range eligible {
result = append(result, u)
}
log.Printf("🎯 [DELIVERY_MODE] Cmd %d: livreurs éligibles %v (catégories: %v)", commandID, result, categories)
return result, nil
}
+68
View File
@@ -0,0 +1,68 @@
// ============================================
// db/gps_links.go
// Génération de liens GPS vers différentes plateformes
// ============================================
package db
import (
"fmt"
)
// 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"`
}
func wazeAppLink(lat, lon float64) string {
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
}
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
return MapLinks{
WazeApp: wazeAppLink(lat, lon),
}
}
// GenerateNavigationLink génère un lien de navigation vers une destination
// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
return wazeAppLink(toLat, toLon)
}
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return nil, fmt.Errorf("position livreur non disponible: %w", err)
}
command, err := d.GetCommandByID(commandID)
if err != nil {
return nil, fmt.Errorf("commande non trouvée: %w", err)
}
var destLat, destLon float64
if v, ok := command["dest_latitude"].(float64); ok {
destLat = v
}
if v, ok := command["dest_longitude"].(float64); ok {
destLon = v
}
if destLat == 0 && destLon == 0 {
return nil, fmt.Errorf("coordonnées destination invalides")
}
return map[string]string{
"waze_app": wazeAppLink(destLat, destLon),
}, nil
}
+150
View File
@@ -0,0 +1,150 @@
// ============================================
// db/commands_history.go
// ============================================
// Fonctions pour l'historique des commandes terminées
package db
import (
"fmt"
"log"
"maps"
"strconv"
)
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]any, error) {
query := `
SELECT
id,
client_order_id AS client_order_number,
username,
status,
adresse,
total_prix::float8 as total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = ? AND status = 'approved'
ORDER BY created_at DESC
`
var commands []map[string]any
if err := d.GDB.Raw(query, username).Scan(&commands).Error; 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)
}
return commands, nil
}
// GetCompletedCommandsWithItems récupère les commandes terminées avec leurs items
func (d *Database) GetCompletedCommandsWithItems(username string) ([]map[string]any, error) {
commands, err := d.GetCompletedCommandsByUsername(username)
if err != nil {
return nil, err
}
var enrichedCommands []map[string]any
for _, command := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
if commandID == 0 {
continue
}
items, err := d.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [GetCompletedWithItems] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]any{}
}
enrichedCommand := make(map[string]any)
maps.Copy(enrichedCommand, command)
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
enrichedCommands = append(enrichedCommands, enrichedCommand)
}
return enrichedCommands, nil
}
// GetCommandsStatsByUsername récupère les statistiques des commandes d'un utilisateur
func (d *Database) GetCommandsStatsByUsername(username string) (map[string]any, error) {
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 = ?
`
var result map[string]any
if err := d.GDB.Raw(query, username).Scan(&result).Error; err != nil {
log.Printf("❌ [GetCommandsStats] Erreur: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des statistiques: %w", err)
}
return result, nil
}
// GetCommandsByStatus récupère les commandes d'un utilisateur par statut
func (d *Database) GetCommandsByStatus(username, status string) ([]map[string]any, error) {
log.Printf("📋 [GetCommandsByStatus] START - username=%s, status=%s", username, status)
query := `
SELECT
id,
client_order_id AS client_order_number,
username,
status,
adresse,
total_prix::float8 as total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = ? AND status = ?
ORDER BY created_at DESC
`
var commands []map[string]any
if err := d.GDB.Raw(query, username, status).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
return commands, nil
}
// GetRecentCompletedOrders récupère les N dernières commandes terminées d'un utilisateur
func (d *Database) GetRecentCompletedOrders(username string, limit int) ([]map[string]any, error) {
query := `
SELECT
id,
client_order_id AS client_order_number,
username,
status,
adresse,
total_prix::float8 as total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = ? AND status = 'approved'
ORDER BY created_at DESC
LIMIT ?
`
var commands []map[string]any
if err := d.GDB.Raw(query, username, limit).Scan(&commands).Error; err != nil {
log.Printf("❌ [GetRecentCompleted] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération: %w", err)
}
return commands, nil
}
+517
View File
@@ -0,0 +1,517 @@
package db
import (
"database/sql"
"fmt"
"log"
"os"
"time"
_ "github.com/lib/pq"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// ============================================
// STRUCTURES
// ============================================
// Database encapsule la connexion à la base de données
type Database struct {
*sql.DB
GDB *gorm.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(50)
db.SetMaxIdleConns(10)
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")
// Initialiser GORM en réutilisant la connexion sql.DB existante
gormDB, err := gorm.Open(postgres.New(postgres.Config{
Conn: db,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
}
// Créer l'instance Database
database := &Database{db, gormDB}
// 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")
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
}
// Migration: proposition de modification d'adresse par admin/cabine
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
}
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
log.Fatalf("❌ Erreur migration unit products: %v", err)
}
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
if _, err = database.Exec(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'baskets' AND column_name = 'quantity'
AND data_type = 'integer'
) THEN
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
END IF;
END
$$;
`); err != nil {
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'command_items' AND column_name = 'quantite'
AND data_type = 'integer'
) THEN
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
END IF;
END
$$;
`); err != nil {
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
}
// Migration: ajouter colonne color pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
log.Fatalf("❌ Erreur migration categories.color: %v", err)
}
// Migration: ajouter colonne is_coming_soon pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
}
// Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
)`); err != nil {
log.Fatalf("❌ Erreur migration app_settings: %v", err)
}
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
}
// Migration: montant de parrainage utilisé pour la commande
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
}
// Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments)
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil {
log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err)
}
// Migration: identifiant de commande par client (numérotation indépendante par client, commence à 1)
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS client_order_id INTEGER NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.client_order_id: %v", err)
}
// Backfill: numéroter les commandes existantes par client dans l'ordre d'insertion
if _, err = database.Exec(`
UPDATE commandes c
SET client_order_id = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
FROM commandes
) sub
WHERE c.id = sub.id AND c.client_order_id = 0
`); err != nil {
log.Fatalf("❌ Erreur backfill commandes.client_order_id: %v", err)
}
// Migration: raison d'annulation par le client
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS cancel_reason TEXT`); err != nil {
log.Fatalf("❌ Erreur migration commandes.cancel_reason: %v", err)
}
// Migration: coordonnées GPS de destination et du livreur
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_latitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.dest_latitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_longitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.dest_longitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_latitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.livreur_latitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_longitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.livreur_longitude: %v", err)
}
// Migration: table de suivi des paiements crypto
if _, err = database.Exec(`
CREATE TABLE IF NOT EXISTS crypto_payments (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
nowpayment_id TEXT NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
price_amount NUMERIC(10,2) NOT NULL,
price_currency VARCHAR(10) NOT NULL DEFAULT 'eur',
pay_currency VARCHAR(20) NOT NULL,
pay_address TEXT NOT NULL,
pay_amount NUMERIC(20,8) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
log.Fatalf("❌ Erreur migration crypto_payments: %v", err)
}
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil {
log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err)
}
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil {
log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err)
}
// Migration: points extra pour tous les pools de points (stockage dynamique par clé)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
}
// 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,
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,
referral_balance NUMERIC(10,2) DEFAULT 0.0,
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 categories
// ============================
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE products
// ============================
`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,
unit VARCHAR(10) NOT NULL DEFAULT 'u',
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 NUMERIC(10,3) 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 NUMERIC(10,3) 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
);`,
`CREATE TABLE IF NOT EXISTS adresse_correction (
id SERIAL PRIMARY KEY,
invalid_address VARCHAR(255) NOT NULL UNIQUE,
correct_address VARCHAR(255) NOT NULL,
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_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
}
+127
View File
@@ -0,0 +1,127 @@
package db
import (
"fmt"
"log"
"time"
"gorm.io/gorm"
)
type jwtToken struct {
ID int `gorm:"primaryKey;autoIncrement"`
UserID int `gorm:"column:user_id;index"`
UserType string `gorm:"column:user_type"`
Token string `gorm:"column:token;uniqueIndex"`
DateSave time.Time `gorm:"column:date_save;autoCreateTime"`
DateFin time.Time `gorm:"column:date_fin"`
}
func (jwtToken) TableName() string { return "jwt_tokens" }
var validTokenTypes = map[string]bool{
"client": true, "admin": true, "cabine": true, "livreur": true,
}
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
if !validTokenTypes[userType] {
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
}
t := jwtToken{UserID: userID, UserType: userType, Token: token, DateFin: expiresAt}
if err := d.GDB.Create(&t).Error; 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
}
func (d *Database) IsTokenValid(token string) (bool, error) {
var count int64
err := d.GDB.Model(&jwtToken{}).
Where("token = ? AND date_fin > ?", token, time.Now()).
Count(&count).Error
return count > 0, err
}
func (d *Database) RevokeToken(token string) error {
result := d.GDB.Where("token = ?", token).Delete(&jwtToken{})
if result.Error != nil {
return fmt.Errorf("erreur lors de la révocation du token: %w", result.Error)
}
if result.RowsAffected > 0 {
log.Printf("✅ Token révoqué avec succès")
}
return nil
}
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
result := d.GDB.Where("user_id = ? AND user_type = ?", userID, userType).Delete(&jwtToken{})
if result.Error != nil {
return fmt.Errorf("erreur lors de la révocation des tokens: %w", result.Error)
}
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", result.RowsAffected, userType, userID)
return nil
}
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]any, error) {
var tokens []jwtToken
err := d.GDB.Where("user_id = ? AND user_type = ? AND date_fin > ?", userID, userType, time.Now()).
Order("date_save DESC").Find(&tokens).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
}
result := make([]map[string]any, len(tokens))
for i, t := range tokens {
truncated := t.Token
if len(truncated) > 20 {
truncated = truncated[:20] + "..."
}
result[i] = map[string]any{
"id": t.ID,
"token": truncated,
"date_save": t.DateSave,
"date_fin": t.DateFin,
"user_type": t.UserType,
}
}
return result, nil
}
func (d *Database) GetTokenInfo(token string) (map[string]any, error) {
var t jwtToken
err := d.GDB.Where("token = ? AND date_fin > ?", token, time.Now()).First(&t).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("token non trouvé ou expiré")
}
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
}
return map[string]any{
"user_id": t.UserID,
"user_type": t.UserType,
"date_save": t.DateSave,
"date_fin": t.DateFin,
}, nil
}
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
type row struct {
UserType string
Count int
}
var rows []row
err := d.GDB.Model(&jwtToken{}).
Select("user_type, COUNT(*) as count").
Where("date_fin > ?", time.Now()).
Group("user_type").
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
}
counts := make(map[string]int, len(rows))
for _, r := range rows {
counts[r.UserType] = r.Count
}
return counts, nil
}
+295
View File
@@ -0,0 +1,295 @@
package db
import (
"fmt"
"gestion/models"
"log"
"slices"
"strings"
"time"
)
type MediaInterface interface {
GetProductID() int
GetType() string
GetURL() string
SetID(int)
}
// 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))
if slices.Contains(validTypes, mediaType) {
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)")
}
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
return fmt.Errorf("path traversal détecté dans l'URL")
}
if !strings.HasPrefix(url, "/uploads/") {
return fmt.Errorf("URL doit commencer par /uploads/")
}
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
for _, char := range dangerousChars {
if strings.Contains(url, char) {
return fmt.Errorf("caractères interdits dans l'URL")
}
}
return nil
}
func (d *Database) CreateMedia(media any) error {
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
m, ok := media.(MediaInterface)
if !ok {
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")
}
productID := m.GetProductID()
mediaType := m.GetType()
mediaURL := m.GetURL()
if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := validateMediaType(mediaType); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := validateMediaURL(mediaURL); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil {
log.Printf("❌ [InsertMedia] %v", err)
return err
}
return nil
}
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error {
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; 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)
}
var result struct {
ID int `gorm:"column:id"`
}
err := d.GDB.Raw(`
INSERT INTO media (product_id, url, type, created_at)
VALUES (?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, time.Now(),
).Scan(&result).Error
if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
return fmt.Errorf("erreur création média: %w", err)
}
if mi, ok := m.(MediaInterface); ok {
mi.SetID(result.ID)
}
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", result.ID, mediaType)
return nil
}
func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [GetMediaByID] %v", err)
return nil, err
}
var media models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération média: %w", err)
}
if media.ID == 0 {
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
return nil, fmt.Errorf("média non trouvé")
}
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
return &media, nil
}
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
if err := validateProductID(productID); err != nil {
log.Printf("❌ [GetMediaByProductID] %v", err)
return nil, err
}
var mediaList []models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at
FROM media WHERE product_id = ?
ORDER BY id ASC`, productID).Scan(&mediaList).Error
if err != nil {
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération médias: %w", err)
}
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
return mediaList, nil
}
func (d *Database) UpdateMedia(media *models.Media) error {
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
}
if err := d.CheckMediaExists(media); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
result := d.GDB.Exec(`UPDATE media SET url = ?, type = ? WHERE id = ?`, media.URL, media.Type, media.ID)
if result.Error != nil {
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour média: %w", result.Error)
}
if result.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
}
func (d *Database) CheckMediaExists(media *models.Media) error {
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)`, media.ID).Scan(&exists).Error; 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)
}
return nil
}
func (d *Database) DeleteMedia(mediaID int) error {
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] %v", err)
return err
}
media := models.Media{ID: mediaID}
if err := d.CheckMediaExists(&media); err != nil {
log.Printf("❌ [DeleteMedia] %v", err)
return err
}
result := d.GDB.Exec(`DELETE FROM media WHERE id = ?`, mediaID)
if result.Error != nil {
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", result.Error)
return fmt.Errorf("erreur suppression média: %w", result.Error)
}
if result.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
}
func (d *Database) DeleteMediaByProductID(productID int) error {
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
if err := validateProductID(productID); err != nil {
log.Printf("❌ [DeleteMediaByProductID] %v", err)
return err
}
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; 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)
}
result := d.GDB.Exec(`DELETE FROM media WHERE product_id = ?`, productID)
if result.Error != nil {
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", result.Error)
return fmt.Errorf("erreur suppression médias: %w", result.Error)
}
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
result.RowsAffected, productID)
return nil
}
+139
View File
@@ -0,0 +1,139 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"gestion/services"
"log"
"time"
)
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username)
notification := map[string]any{
"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)
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
}
}
log.Printf("📬 Notification envoyée à %s: %s", username, message)
return nil
}
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username)
notification := map[string]any{
"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)
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
}
}
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
return nil
}
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
var users []struct {
Username string `gorm:"column:username"`
}
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
notification := map[string]any{
"command_id": commandID,
"type": "new_order",
"message": msg,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
count := 0
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID
capturedMsg := msg
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", capturedMsg))
}
}
count++
}
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
}
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
var users []struct {
Username string `gorm:"column:username"`
}
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
log.Printf("❌ [ALERT_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
body := fmt.Sprintf("%s — livreur : %s", alertMessage, livreurUsername)
notification := map[string]any{
"alert_id": alertID,
"type": "alert",
"message": body,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
count := 0
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID
capturedBody := body
go services.TelegramBot.SendMessage(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", capturedBody))
}
}
count++
}
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
}
+69
View File
@@ -0,0 +1,69 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
result := d.GDB.Model(&models.Client{}).
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
Update("parrain", parrainUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client introuvable ou parrain déjà défini")
}
return nil
}
func (d *Database) GetClientParrain(clientUsername string) (string, error) {
var parrain string
err := d.GDB.Table("clients").
Select("parrain").
Where("username = ?", clientUsername).
Scan(&parrain).Error
return parrain, err
}
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
var clients []models.Client
err := d.GDB.
Where("parrain = ?", parrainUsername).
Find(&clients).Error
return clients, err
}
func (d *Database) GetParrainStats() ([]map[string]any, error) {
var rows []struct {
Parrain string `gorm:"column:parrain"`
FilleulCount int `gorm:"column:filleul_count"`
ReferralBalance float64 `gorm:"column:referral_balance"`
}
err := d.GDB.Raw(`
SELECT
c.parrain,
COUNT(c.username) AS filleul_count,
p.referral_balance
FROM clients c
JOIN clients p ON p.username = c.parrain
WHERE c.parrain IS NOT NULL AND c.parrain <> ''
GROUP BY c.parrain, p.referral_balance
ORDER BY filleul_count DESC
`).Scan(&rows).Error
if err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(rows))
for _, r := range rows {
result = append(result, map[string]any{
"parrain": r.Parrain,
"filleul_count": r.FilleulCount,
"referral_balance": r.ReferralBalance,
})
}
return result, nil
}
+85
View File
@@ -0,0 +1,85 @@
package db
import (
"fmt"
"gestion/models"
"log"
"gorm.io/gorm"
)
func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) {
p := models.CryptoPayment{
CommandID: commandID,
NowPaymentID: nowPaymentID,
Status: status,
PriceAmount: priceAmount,
PriceCurrency: priceCurrency,
PayCurrency: payCurrency,
PayAddress: payAddress,
PayAmount: payAmount,
}
if err := d.GDB.Create(&p).Error; err != nil {
return nil, fmt.Errorf("CreateCryptoPayment: %w", err)
}
return &p, nil
}
func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) {
var p models.CryptoPayment
err := d.GDB.Where("command_id = ?", commandID).Order("created_at DESC").First(&p).Error
if err != nil {
if isNotFound(err) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) {
var p models.CryptoPayment
err := d.GDB.Where("nowpayment_id = ?", nowPaymentID).First(&p).Error
if err != nil {
if isNotFound(err) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) {
var payments []models.CryptoPayment
err := d.GDB.Where("status NOT IN ?", []string{"finished", "failed", "expired", "refunded"}).
Order("created_at ASC").Find(&payments).Error
return payments, err
}
func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error {
return d.GDB.Model(&models.CryptoPayment{}).Where("id = ?", id).
Updates(map[string]any{"status": status, "pay_amount": payAmount}).Error
}
func (d *Database) ActivateCryptoCommand(commandID int) error {
return d.GDB.Exec(`UPDATE commandes SET status = 'pending', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
}
func (d *Database) CancelCryptoCommand(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
type item struct {
ProductID int
Quantite float64
}
var items []item
if err := tx.Raw(`SELECT product_id, quantite FROM command_items WHERE command_id = ?`, commandID).Scan(&items).Error; err != nil {
return err
}
for _, it := range items {
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
log.Printf("[CANCEL CRYPTO] erreur restauration stock produit %d: %v", it.ProductID, err)
}
}
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
})
}
+226
View File
@@ -0,0 +1,226 @@
package db
import (
"fmt"
"gestion/models"
"log"
"time"
)
// CreateProduct crée un nouveau produit avec ses prix
func (d *Database) CreateProduct(product any) 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
GetUnit() string
GetPrices() []models.ProductPrice
SetID(int)
SetCreatedAt(time.Time)
SetUpdatedAt(time.Time)
}
p, ok := product.(ProductInterface)
if !ok {
if prodPtr, isPtr := product.(*models.Product); isPtr {
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
p = prodPtr
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] Unit: %s", p.GetUnit())
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
}
now := time.Now()
var result struct {
ID int `gorm:"column:id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err := d.GDB.Raw(`
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now,
).Scan(&result).Error
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", result.ID)
p.SetID(result.ID)
p.SetCreatedAt(result.CreatedAt)
p.SetUpdatedAt(result.UpdatedAt)
for i, price := range p.GetPrices() {
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
result.ID, price.Quantity, price.Price).Error
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=%g, price=%.2f", i, price.Quantity, price.Price)
}
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
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
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at
FROM products
WHERE id = ?`, id).Scan(&p).Error
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)
prices, err := d.GetProductPrices(p.ID)
if err != nil {
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
p.Prices = []models.ProductPrice{}
} 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")
var products []models.Product
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at
FROM products
ORDER BY id ASC`).Scan(&products).Error
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
return nil, err
}
for i := range products {
prices, err := d.GetProductPrices(products[i].ID)
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
products[i].Prices = []models.ProductPrice{}
} else {
products[i].Prices = prices
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID)
}
media, err := d.GetMediaByProductID(products[i].ID)
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err)
products[i].Media = []models.Media{}
} else {
products[i].Media = media
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID)
}
}
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 (d *Database) GetProductsByCategory(category string) ([]models.Product, error) {
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
var products []models.Product
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at
FROM products
WHERE category = ?
ORDER BY created_at DESC`, category).Scan(&products).Error
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)
}
for i := range products {
prices, err := d.GetProductPrices(products[i].ID)
if err != nil {
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
products[i].Prices = []models.ProductPrice{}
} else {
products[i].Prices = prices
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID)
}
}
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
return products, nil
}
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, stock float64, prices []models.ProductPrice) error {
err := d.GDB.Exec(`
UPDATE products
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ?
WHERE id = ?`,
name, category, description, stock, unit, time.Now(), productID).Error
if err != nil {
return fmt.Errorf("erreur mise à jour produit: %w", err)
}
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
for _, price := range prices {
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`,
productID, price.Quantity, price.Price).Error; err != nil {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err)
}
}
return nil
}
// DeleteProduct supprime un produit
func (d *Database) DeleteProduct(productID int) error {
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
if result.Error != nil {
return fmt.Errorf("erreur suppression produit: %v", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit introuvable")
}
return nil
}
func (d *Database) GetProductNameByID(productID int) (string, error) {
var result struct {
Name string `gorm:"column:name"`
}
err := d.GDB.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&result).Error
if err != nil {
return "", err
}
if result.Name == "" {
return "", fmt.Errorf("produit non trouvé")
}
return result.Name, nil
}
+45
View File
@@ -0,0 +1,45 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
var prices []models.ProductPrice
if err := d.GDB.Where("product_id = ?", productID).Order("quantity ASC").Find(&prices).Error; err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
return prices, nil
}
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error {
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price}
if err := d.GDB.Create(&p).Error; err != nil {
return fmt.Errorf("erreur création prix: %w", err)
}
return nil
}
func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float64) error {
result := d.GDB.Model(&models.ProductPrice{}).Where("id = ?", priceID).
Updates(map[string]any{"quantity": quantity, "price": price})
if result.Error != nil {
return fmt.Errorf("erreur mise à jour prix: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
func (d *Database) DeleteProductPrice(priceID int) error {
result := d.GDB.Delete(&models.ProductPrice{}, priceID)
if result.Error != nil {
return fmt.Errorf("erreur suppression prix: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
+219
View File
@@ -0,0 +1,219 @@
// ============================================
// db/queue_auto_next_db.go
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
// ============================================
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"slices"
)
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
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
}
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)
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
if slices.Contains(nonAssignableStatuses, currentStatus) {
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue
}
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
d.AddCommandLog(commandID, "next_in_queue",
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
"system")
return nil
}
// 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)
}
Redis.Del(RedisCtx, commandKey)
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
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
}
+74
View File
@@ -0,0 +1,74 @@
package db
import (
"fmt"
"gestion/models"
"gorm.io/gorm"
)
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
var balance float64
err := d.GDB.Table("clients").Select("referral_balance").Where("username = ?", username).Scan(&balance).Error
if err != nil {
return 0, err
}
return balance, nil
}
func (d *Database) CreditClientReferral(username string, amount float64) error {
if amount <= 0 {
return fmt.Errorf("le montant doit être positif")
}
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
"referral_balance": gorm.Expr("referral_balance + ?", amount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) DebitReferralBalance(username string, amount float64) error {
if amount <= 0 {
return nil
}
return d.GDB.Transaction(func(tx *gorm.DB) error {
var balance float64
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
return fmt.Errorf("client non trouvé")
}
if balance < amount {
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
}
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
})
}
func (d *Database) ResetClientReferralBalance(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("referral_balance", 0)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) UseClientReferralBalance(tx *gorm.DB, username string, amount float64) error {
if amount <= 0 {
return nil
}
var balance float64
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
return fmt.Errorf("client non trouvé")
}
if balance < amount {
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
}
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
}
+145
View File
@@ -0,0 +1,145 @@
// ============================================
// db/cancel_sanctions_db.go
// GESTION DES SANCTIONS ÉVOLUTIVES
// ============================================
package db
import (
"fmt"
"gestion/models"
"log"
"sort"
"gorm.io/gorm"
)
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Table("clients").Select("COALESCE(cancellations_count, 0) as count").Where("username = ?", username).Scan(&result).Error
if err != nil {
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
}
return result.Count, nil
}
// IncrementClientCancellationsCount incrémente le compteur d'annulations
func (d *Database) IncrementClientCancellationsCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
})
if result.Error != nil {
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
return fmt.Errorf("erreur incrémentation: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
if len(tiers) == 0 {
return 0
}
sorted := make([]models.PenaltyTier, len(tiers))
copy(sorted, tiers)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].MinCancel > sorted[j].MinCancel
})
for _, t := range sorted {
if count >= t.MinCancel {
return t.Amount
}
}
return sorted[len(sorted)-1].Amount
}
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return 0, err
}
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err)
settings = DefaultSettings()
}
penalty := penaltyForCount(count, settings.PenaltyTiers)
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty)
return penalty, nil
}
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
penalty, err := d.CalculateCancellationPenalty(username)
if err != nil {
return 0, err
}
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
if err := d.IncrementClientCancellationsCount(username); err != nil {
return 0, err
}
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty))
if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return 0, fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
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]any, error) {
nextPenalty, err := d.CalculateCancellationPenalty(username)
if err != nil {
return nil, err
}
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return nil, err
}
settings, _ := d.GetSettings()
client, err := d.GetClientByUsername(username)
var currentAmende float64
if err == nil {
currentAmende = client.Amende
}
return map[string]any{
"cancellations_count": count,
"current_amende": currentAmende,
"next_penalty": nextPenalty,
"penalty_tiers": settings.PenaltyTiers,
"warning": "Une amende sera appliquée lors de la prochaine annulation tardive",
}, nil
}
+251
View File
@@ -0,0 +1,251 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"strconv"
"gorm.io/gorm"
)
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
func DefaultDeliverySchedule() models.DeliverySchedule {
day := models.DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
return models.DeliverySchedule{
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
Friday: day, Saturday: day, Sunday: day,
}
}
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
func CalcPointsFromTiers(total float64, tiers []models.PointsTier) int {
for _, t := range tiers {
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
return t.Points
}
}
return 0
}
// DefaultSettings retourne les paramètres par défaut
func DefaultSettings() models.AppSettings {
return models.AppSettings{
PenaltiesEnabled: true,
ShowAmendeScore: true,
PenaltyTiers: []models.PenaltyTier{
{MinCancel: 0, Amount: 20},
{MinCancel: 1, Amount: 50},
{MinCancel: 2, Amount: 100},
{MinCancel: 3, Amount: 150},
},
PointsEnabled: true,
ReferralEnabled: true,
ReferralAmount: 0,
PointsPools: []models.PointsPool{
{
Key: "pool_0",
Name: "Pool 1",
Categories: []string{},
Tiers: []models.PointsTier{
{Min: 30, Max: 50, Points: 1},
{Min: 60, Max: 150, Points: 2},
{Min: 160, Max: 300, Points: 3},
{Min: 310, Max: 400, Points: 5},
{Min: 401, Max: 0, Points: 10},
},
},
{
Key: "pool_1",
Name: "Pool 2",
Categories: []string{},
Tiers: []models.PointsTier{
{Min: 30, Max: 100, Points: 1},
{Min: 110, Max: 200, Points: 2},
{Min: 210, Max: 0, Points: 3},
},
},
},
ShopName: "Milieu-Nantais",
DeliveryMode: models.DeliveryModeConfig{
Mode: "single",
CategoryRoutes: []models.CategoryRoute{},
},
DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
"44400", "44880", "44120", "44230", "44115",
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
}},
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
"44860", "44220", "44118", "44710", "44690", "44119",
}},
},
Telegram2FAEnabled: false,
}
}
// GetSettings récupère les paramètres depuis la DB
func (d *Database) GetSettings() (models.AppSettings, error) {
settings := DefaultSettings()
var rows []struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
if err := d.GDB.Table("app_settings").Select("key, value").Scan(&rows).Error; err != nil {
return settings, fmt.Errorf("erreur lecture settings: %w", err)
}
for _, row := range rows {
switch row.Key {
case "penalties_enabled":
settings.PenaltiesEnabled = row.Value == "true"
case "show_amende_score":
settings.ShowAmendeScore = row.Value == "true"
case "points_enabled":
settings.PointsEnabled = row.Value == "true"
case "points_pools":
var pools []models.PointsPool
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
settings.PointsPools = pools
}
case "referral_enabled":
settings.ReferralEnabled = row.Value == "true"
case "referral_amount":
if v, err := strconv.ParseFloat(row.Value, 64); err == nil {
settings.ReferralAmount = v
}
case "crypto_payment_enabled":
settings.CryptoPaymentEnabled = row.Value == "true"
case "crypto_only":
settings.CryptoOnly = row.Value == "true"
case "nowpayments_api_key":
settings.NowPaymentsAPIKey = row.Value
case "nowpayments_ipn_secret":
settings.NowPaymentsIPNSecret = row.Value
case "nowpayments_currencies":
var currencies []string
if err := json.Unmarshal([]byte(row.Value), &currencies); err == nil {
settings.NowPaymentsCurrencies = currencies
}
case "delivery_schedule":
var sched models.DeliverySchedule
if err := json.Unmarshal([]byte(row.Value), &sched); err == nil {
settings.DeliverySchedule = sched
}
case "postal_zones":
var zones []models.PostalZone
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
settings.PostalZones = zones
}
case "telegram_bot_token":
settings.TelegramBotToken = row.Value
case "telegram_bot_username":
settings.TelegramBotUsername = row.Value
case "telegram_notifications_enabled":
settings.TelegramNotificationsEnabled = row.Value == "true"
case "delivery_mode":
var mode models.DeliveryModeConfig
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
settings.DeliveryMode = mode
}
case "telegram_2fa_enabled":
settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name":
settings.ShopName = row.Value
}
}
return settings, nil
}
// UpdateSettings sauvegarde les paramètres dans la DB
func (d *Database) UpdateSettings(s models.AppSettings) error {
boolStr := func(b bool) string {
if b {
return "true"
}
return "false"
}
if s.PointsPools == nil {
s.PointsPools = []models.PointsPool{}
}
for i := range s.PointsPools {
if s.PointsPools[i].Categories == nil {
s.PointsPools[i].Categories = []string{}
}
if s.PointsPools[i].Tiers == nil {
s.PointsPools[i].Tiers = []models.PointsTier{}
}
}
poolsJSON, err := json.Marshal(s.PointsPools)
if err != nil {
return fmt.Errorf("erreur sérialisation pools: %w", err)
}
if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{}
}
currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies)
if err != nil {
return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err)
}
schedJSON, err := json.Marshal(s.DeliverySchedule)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
}
if s.PostalZones == nil {
s.PostalZones = []models.PostalZone{}
}
zonesJSON, err := json.Marshal(s.PostalZones)
if err != nil {
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
}
if s.DeliveryMode.CategoryRoutes == nil {
s.DeliveryMode.CategoryRoutes = []models.CategoryRoute{}
}
deliveryModeJSON, err := json.Marshal(s.DeliveryMode)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
{"crypto_only", boolStr(s.CryptoOnly)},
{"nowpayments_api_key", s.NowPaymentsAPIKey},
{"nowpayments_ipn_secret", s.NowPaymentsIPNSecret},
{"nowpayments_currencies", string(currenciesJSON)},
{"delivery_schedule", string(schedJSON)},
{"postal_zones", string(zonesJSON)},
{"telegram_bot_token", s.TelegramBotToken},
{"telegram_bot_username", s.TelegramBotUsername},
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
{"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName},
}
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
return d.GDB.Transaction(func(tx *gorm.DB) error {
for _, p := range pairs {
if err := tx.Exec(upsert, p[0], p[1]).Error; err != nil {
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
}
}
return nil
})
}
+163
View File
@@ -0,0 +1,163 @@
package db
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
// MigrateAddTelegramColumns ajoute les colonnes telegram_chat_id si elles n'existent pas
func (d *Database) MigrateAddTelegramColumns() {
migrations := []string{
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
}
for _, q := range migrations {
if err := d.GDB.Exec(q).Error; err != nil {
log.Printf("⚠️ [TELEGRAM_MIGRATION] %v", err)
}
}
log.Println("✅ [TELEGRAM] Colonnes telegram_chat_id vérifiées")
}
const linkTokenTTL = 10 * time.Minute
// GenerateLinkToken crée un token aléatoire sécurisé et le stocke dans Redis (10 min)
func GenerateLinkToken(username, role string) (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("génération token: %w", err)
}
token := hex.EncodeToString(raw)
data := models.TelegramLinkData{Username: username, Role: role}
val, err := json.Marshal(data)
if err != nil {
return "", err
}
key := fmt.Sprintf("telegram:link:%s", token)
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
return "", fmt.Errorf("Redis SET: %w", err)
}
return token, nil
}
// ValidateAndConsumeLinkToken valide le token, retourne les données, puis le supprime
func ValidateAndConsumeLinkToken(token string) (username, role string, err error) {
key := fmt.Sprintf("telegram:link:%s", token)
val, err := Redis.Get(RedisCtx, key).Bytes()
if err != nil {
return "", "", fmt.Errorf("token invalide ou expiré")
}
var data models.TelegramLinkData
if err := json.Unmarshal(val, &data); err != nil {
return "", "", fmt.Errorf("données corrompues")
}
Redis.Del(RedisCtx, key)
return data.Username, data.Role, nil
}
func (d *Database) SaveClientTelegramChatID(username string, chatID int64) error {
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
}
func (d *Database) GetClientTelegramChatID(username string) (int64, bool, error) {
var result struct {
ChatID *int64 `gorm:"column:telegram_chat_id"`
}
if err := d.GDB.Table("clients").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
return 0, false, err
}
if result.ChatID == nil {
return 0, false, nil
}
return *result.ChatID, true, nil
}
func (d *Database) DeleteClientTelegramChatID(username string) error {
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
}
func (d *Database) SaveUserTelegramChatID(username string, chatID int64) error {
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
}
func (d *Database) GetUserTelegramChatID(username string) (int64, bool, error) {
var result struct {
ChatID *int64 `gorm:"column:telegram_chat_id"`
}
if err := d.GDB.Table("users").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
return 0, false, err
}
if result.ChatID == nil {
return 0, false, nil
}
return *result.ChatID, true, nil
}
func (d *Database) DeleteUserTelegramChatID(username string) error {
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
}
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
var clientResult struct {
Username string `gorm:"column:username"`
}
if err = d.GDB.Table("clients").Select("username").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&clientResult).Error; err == nil && clientResult.Username != "" {
return clientResult.Username, "client", nil
}
var userResult struct {
Username string `gorm:"column:username"`
Role string `gorm:"column:role"`
}
if err = d.GDB.Model(&models.User{}).Select("username, role").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&userResult).Error; err == nil && userResult.Username != "" {
return userResult.Username, userResult.Role, nil
}
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
}
// ── 2FA sessions ─────────────────────────────────────────────────────────────
const twoFASessionTTL = 5 * time.Minute
type twoFASessionData struct {
Username string `json:"username"`
Code string `json:"code"`
}
func Store2FASession(sessionToken, username, code string) error {
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
if err != nil {
return err
}
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
}
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
func Verify2FASession(sessionToken, code string) (string, error) {
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
if err != nil {
return "", fmt.Errorf("session invalide ou expirée")
}
var d twoFASessionData
if err := json.Unmarshal(val, &d); err != nil {
return "", fmt.Errorf("données corrompues")
}
if d.Code != code {
return "", fmt.Errorf("code incorrect")
}
return d.Username, nil
}
+90
View File
@@ -0,0 +1,90 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
func (d *Database) CreateUser(user *models.User) error {
if err := d.GDB.Create(user).Error; err != nil {
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
}
return nil
}
func (d *Database) GetAllUsers() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
}
return users, nil
}
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
return users, nil
}
func (d *Database) UpdateUser(user *models.User) error {
result := d.GDB.Model(user).Updates(models.User{
Username: user.Username,
Password: user.Password,
Role: user.Role,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
return nil
}
func (d *Database) DeleteUser(id int) error {
var user models.User
if err := d.GDB.First(&user, id).Error; err != nil {
if isNotFound(err) {
return fmt.Errorf("utilisateur non trouvé")
}
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
}
_ = d.RevokeAllUserTokens(id, user.Role)
result := d.GDB.Delete(&models.User{}, id)
if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, user.Role)
return nil
}
func (d *Database) GetUserByID(id int) (*models.User, error) {
var user models.User
if err := d.GDB.First(&user, id).Error; err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("utilisateur non trouvé")
}
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) {
var user models.User
if err := d.GDB.Where("username = ?", username).First(&user).Error; err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("utilisateur non trouvé")
}
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
}
return &user, nil
}
+8
View File
@@ -0,0 +1,8 @@
package db
import "gorm.io/gorm"
// isNotFound retourne true si l'erreur GORM est un "record not found"
func isNotFound(err error) bool {
return err == gorm.ErrRecordNotFound
}
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"context"
"gestion/models"
"log"
"os"
"github.com/redis/go-redis/v9"
)
var (
Redis *redis.Client
RedisCtx = context.Background()
)
type CommandWithDistance struct {
CommandID int
Address string
Lat float64
Lng float64
Distance float64
EstimatedETA int
QueueItem models.CommandQueue
}
const (
// Temps moyen estimé par livraison (en minutes)
AVG_DELIVERY_TIME = 15
// Nombre maximum de commandes par livreur
MAX_COMMANDS_PER_DELIVERYMAN = 20
)
// ============================================
// 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,170 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"math"
)
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
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)
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é
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
}
if !d.CanDeliverymanAcceptCommands(username) {
continue
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
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)
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
}
}
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")
}
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,104 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
)
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
key := fmt.Sprintf("delivery:location:%s", username)
location := map[string]any{
"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)
statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil || statusData == "" {
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(statusData), &status)
if status.Status == "offline" {
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
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]any
if err := json.Unmarshal([]byte(data), &location); err != nil {
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
}
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)
}
}
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
}
+186
View File
@@ -0,0 +1,186 @@
package db
import (
"fmt"
"gestion/services"
"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]any{
"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)
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),
})
}
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", d.GetClientOrderID(commandID), notifType)
channel := fmt.Sprintf("notifications:command:%d", commandID)
Redis.Publish(RedisCtx, channel, 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 {
return services.MinETA
}
from := services.Coordinates{
Latitude: livreurLat,
Longitude: livreurLng,
}
to := services.Coordinates{
Latitude: destLat,
Longitude: destLng,
}
eta, _, err2 := services.CalculateETAWithTomTom(from, to)
if err2 != nil {
distance := services.CalculateDistance(from, to)
eta = services.CalculateETA(distance)
}
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]any, 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]any{
"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)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package db
import "fmt"
func extractCommandID(member any) 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, ""}
}
+107
View File
@@ -0,0 +1,107 @@
// db/redis_position.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"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)
}
// 🔹 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
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"encoding/json"
"fmt"
"log"
"time"
)
// PublishCommandEvent publie un événement de commande
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
channel := fmt.Sprintf("events:command:%d", commandID)
event := map[string]any{
"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]any{
"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,419 @@
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)
}
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)
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")
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()
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
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)
}
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
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
}
+266
View File
@@ -0,0 +1,266 @@
// db/redis_queue_cleanup.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
// 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...")
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 {
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
}
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]any, error) {
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
if err != nil {
return nil, err
}
report := map[string]any{
"total_commands": len(keys),
"valid_commands": 0,
"invalid_commands": 0,
"invalid_details": []map[string]any{},
"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]any), map[string]any{
"command_id": queueItem.CommandID,
"issues": issues,
"data": queueItem,
})
} else {
report["valid_commands"] = report["valid_commands"].(int) + 1
}
}
return report, nil
}
+155
View File
@@ -0,0 +1,155 @@
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]any, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
activeCount, _ := d.CountActiveDeliverymen()
var commands []map[string]any
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
}
commands = append(commands, map[string]any{
"position": i + 1,
"command_id": commandID,
"address": queueItem.Address,
"estimated_eta": queueItem.EstimatedETA,
"created_at": queueItem.CreatedAt,
"lat": queueItem.Lat,
"lng": queueItem.Lng,
})
}
canAcceptMore := true
if activeCount > 1 {
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
}
return map[string]any{
"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]any, error) {
overview := make(map[string]any)
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
overview["general_queue"] = generalQueueSize
deliverymanQueues := make(map[string]any)
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]any{
"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
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]any, error) {
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
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
}
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]any{
"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,604 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
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)
}
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
}
var newStatus string
var currentCommand int
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
newStatus = "busy"
currentCommand = 0
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
} else {
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)
}
}
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
}
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
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
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
return true
}
if status.Status == "offline" {
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
}
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)
}
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
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)
}
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Incr(RedisCtx, counterKey)
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)
}
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
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: 0,
}
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)
}
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
}
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 {
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
return d.AddToGeneralQueue(queueItem)
}
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)
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
}
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
}
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
if err != nil {
d.AddToGeneralQueue(queueItem)
continue
}
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))
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) {
var available []models.DeliveryPersonStatus
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status == "available" {
available = append(available, s)
}
})
return available, err
}
// GetAllActiveDeliveryPersons retourne les livreurs actifs pouvant accepter des commandes
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
var active []models.DeliveryPersonStatus
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" && d.CanDeliverymanAcceptCommands(s.Username) {
active = append(active, s)
}
})
return active, err
}
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
func (d *Database) CountActiveDeliverymen() (int, error) {
count := 0
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" {
count++
}
})
return count, err
}
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
found := ""
d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) { //nolint
if found == "" && s.Status != "offline" {
found = s.Username
}
})
if found == "" {
return "", fmt.Errorf("aucun livreur actif trouvé")
}
return found, nil
}
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
var usernames []string
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" {
usernames = append(usernames, s.Username)
}
})
return usernames, err
}
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
func (d *Database) SyncAllDeliverymanStatuses() error {
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
return d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
d.UpdateDeliverymanStatusBasedOnQueue(s.Username)
})
}
// GetDeliverymanCapacityReport génère un rapport détaillé
func (d *Database) GetDeliverymanCapacityReport() (map[string]any, error) {
report := map[string]any{
"total_deliverymen": 0,
"available": 0,
"busy_full": 0,
"busy_delivering": 0,
"offline": 0,
"details": []map[string]any{},
}
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", s.Username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
canAccept := d.CanDeliverymanAcceptCommands(s.Username)
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
switch {
case s.Status == "offline":
report["offline"] = report["offline"].(int) + 1
case s.Status == "busy" && queueSize >= MAX_COMMANDS_PER_DELIVERYMAN:
report["busy_full"] = report["busy_full"].(int) + 1
case s.Status == "busy":
report["busy_delivering"] = report["busy_delivering"].(int) + 1
case canAccept:
report["available"] = report["available"].(int) + 1
}
report["details"] = append(report["details"].([]map[string]any), map[string]any{
"username": s.Username,
"status": s.Status,
"queue_size": queueSize,
"capacity": fmt.Sprintf("%d/10", queueSize),
"can_accept": canAccept,
"current_order": s.CurrentCommand,
})
})
return report, err
}
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) 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
if err := json.Unmarshal([]byte(data), &status); err != nil {
continue
}
fn(status)
}
return 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
}
+403
View File
@@ -0,0 +1,403 @@
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.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
}
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:16
container_name: gestion_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gestion-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7
container_name: gestion_redis
restart: unless-stopped
command:
[
"redis-server",
"--appendonly",
"yes",
"--requirepass",
"${REDIS_PASSWORD}",
]
ports:
- "${REDIS_PORT}:6379"
volumes:
- redis_data:/data
networks:
- gestion-net
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
gestion-net:
volumes:
postgres_data:
redis_data:
+61
View File
@@ -0,0 +1,61 @@
module gestion
go 1.24.4
require (
github.com/gabriel-vasile/mimetype v1.4.9
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/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.0
golang.org/x/crypto v0.40.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
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/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/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // 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/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/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
)
+136
View File
@@ -0,0 +1,136 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+77
View File
@@ -0,0 +1,77 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"github.com/gin-gonic/gin"
)
func AddAddress(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
}
var req struct {
CorrectAddress string `json:"correct_address" binding:"required"`
InvalidAddress string `json:"invalid_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
if err := database.AddAddress(req.CorrectAddress, req.InvalidAddress); err != nil {
utils.ServerErr(c, "Impossible d'ajouter l'adresse", err)
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Adresses ajoutées avec succès"})
}
func DeleteAddress(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
}
var req struct {
CorrectAddress string `json:"correct_address" binding:"required"`
InvalidAddress string `json:"invalid_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
if err := database.DeleteAddress(req.InvalidAddress, req.CorrectAddress); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'adresse", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Adresses supprimées avec succès"})
}
func GetAllAddress(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
}
getAddress, err := database.AllAddress()
if err != nil {
utils.ServerErr(c, "Impossible de récupérer les adresses", err)
return
}
c.JSON(http.StatusOK, gin.H{"addresses": getAddress})
}
+223
View File
@@ -0,0 +1,223 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"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
}
var req struct {
Message string `json:"message"`
}
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req)
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr, req.Message)
if err != nil {
utils.ServerErr(c, "Impossible de créer l'alerte", err)
return
}
go database.NotifyAllAdminCabineAlert(alert.ID, usernameStr, req.Message)
c.JSON(http.StatusCreated, 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
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
if err = database.DeleteAlertPolicy(alertID); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
return
}
c.JSON(http.StatusOK, 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
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
c.JSON(http.StatusOK, 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)
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
}
if err = database.EndAlert(alertID); err != nil {
utils.ServerErr(c, "Impossible de mettre fin à l'alerte", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Alerte terminée avec succès",
"alert_id": alertID,
"user": usernameStr,
})
}
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 {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
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 {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
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 {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
+911
View File
@@ -0,0 +1,911 @@
package handlers
import (
"crypto/rand"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
clientTokenDuration = 5 * time.Hour
adminTokenDuration = 10 * time.Hour
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET"))
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET"))
)
func generateClientToken(client *models.Client) (string, error) {
sessionID := utils.GenerateSessionID()
claims := models.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)
if err != nil {
return "", err
}
return tokenString, nil
}
func generateAdminToken(user *models.User) (string, error) {
sessionID := utils.GenerateSessionID()
claims := models.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)
if err != nil {
return "", err
}
return tokenString, nil
}
// RegisterClient crée un nouveau compte client
func RegisterClient(c *gin.Context) {
var req models.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",
})
return
}
// Sanitize text inputs
req.Username = utils.StripHTML(req.Username)
req.Nom = utils.StripHTML(req.Nom)
req.Prenom = utils.StripHTML(req.Prenom)
// Validation téléphone
if !utils.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 := utils.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 = ""
c.JSON(http.StatusCreated, models.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,
},
})
}
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
func AdminCreateClient(c *gin.Context) {
var req models.RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "detail": err.Error()})
return
}
// Sanitize text inputs
req.Username = utils.StripHTML(req.Username)
req.Nom = utils.StripHTML(req.Nom)
req.Prenom = utils.StripHTML(req.Prenom)
if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
database := c.MustGet("database").(*db.Database)
if existing, _ := database.GetClientByUsername(req.Username); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
if existing, _ := database.GetClientByTelephone(normalizedPhone); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client := &models.Client{
Username: req.Username,
Password: string(hashed),
Nom: strings.TrimSpace(req.Nom),
Prenom: strings.TrimSpace(req.Prenom),
Telephone: normalizedPhone,
MustChangePassword: true,
}
if err := database.CreateClient(client); err != nil {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Client créé avec succès",
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
},
})
}
func cryptoRandInt() int {
b := make([]byte, 4)
rand.Read(b)
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
}
// LoginClient authentifie un client
func LoginClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.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
}
client, err := database.GetClientByUsername(req.Username)
if err != nil || client == 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
}
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
if linked {
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
sessionToken := uuid.New().String()
if err := db.Store2FASession(sessionToken, client.Username, code); err == nil {
msg := fmt.Sprintf("🔐 Code de vérification : <b>%s</b>\n\nValable 5 minutes.", code)
services.TelegramBot.SendMessage(chatID, msg)
c.JSON(http.StatusOK, gin.H{
"requires_2fa": true,
"session_token": sessionToken,
})
return
}
}
}
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
}
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
}
c.JSON(http.StatusOK, models.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,
"must_change_password": client.MustChangePassword,
},
})
}
func Verify2FAClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
SessionToken string `json:"session_token" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
username, err := db.Verify2FASession(req.SessionToken, req.Code)
if err != nil {
log.Printf("❌ [2FA] Échec vérification: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
client, err := database.GetClientByUsername(username)
if err != nil || client == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
token, err := generateClientToken(client)
if err != nil {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
}
c.JSON(http.StatusOK, models.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,
"must_change_password": client.MustChangePassword,
},
})
}
func GetClient2FAStatus(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
settings, _ := database.GetSettings()
c.JSON(http.StatusOK, gin.H{
"two_fa_enabled": client.TwoFAEnabled,
"telegram_linked": tgLinked,
"admin_2fa_enabled": settings.Telegram2FAEnabled,
})
}
func ToggleClient2FA(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
client, err := database.GetClientByID(clientID)
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
if req.Enabled {
_, linked, _ := database.GetClientTelegramChatID(client.Username)
if !linked {
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
return
}
settings, _ := database.GetSettings()
if !settings.Telegram2FAEnabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
return
}
}
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
}
func ChangePassword(c *gin.Context) {
var req struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"})
}
// LogoutClient déconnecte un client
func LogoutClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
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)
}
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// RegisterAdmin crée un nouvel utilisateur admin/cabine/livreur
func RegisterAdmin(c *gin.Context) {
var req models.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)
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
}
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
}
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
}
user.Password = ""
c.JSON(http.StatusCreated, models.LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: user,
})
}
// LoginAdmin authentifie un admin/cabine/livreur
func LoginAdmin(c *gin.Context) {
var req models.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
}
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
}
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
}
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
}
c.JSON(http.StatusOK, models.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
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,
"amende": client.Amende,
"points_extra": client.PointsExtra,
},
})
}
// 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": models.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)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
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)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
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)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
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,
"points_extra": cl.PointsExtra,
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
"referral_balance": cl.ReferralBalance,
})
}
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
}
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
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
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
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é"})
}
func CreateUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var user models.User
if err := c.ShouldBindJSON(&user); err != nil {
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err := database.CreateUser(&user)
if err != nil {
log.Printf("❌ [CREATE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
return
}
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
}
+669
View File
@@ -0,0 +1,669 @@
// ============================================
// handlers/cabine_handlers.go - COMPLET
// INCLUT: SetCommandDestinationCoordinates
// ============================================
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/utils"
"log"
"net/http"
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// 0️⃣ FONCTION ADMIN: SET DESTINATION COORDINATES
// ============================================
// SetCommandDestinationCoordinates stocke les coordonnées destination en Redis
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
}
if !utils.CheckCommand(commandID, database) {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
}
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",
})
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,
"amende": client.Amende,
"points_extra": client.PointsExtra,
"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,
"amende": client.Amende,
"points_extra": client.PointsExtra,
},
"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"}
if !slices.Contains(allowedStatuses, status) {
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",
})
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)",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"livreur": livreurUsername,
"position": position,
})
}
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)
}
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",
})
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",
})
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",
})
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,
"note",
fmt.Sprintf("Note cabine: %s", req.Message),
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur ajout support",
})
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",
})
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",
"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
}
if status == "livre" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande a déjà été validée",
"current_status": status,
})
return
}
validStatuses := []string{"assigned", "en_route", "pending", "priority"}
if !slices.Contains(validStatuses, status) {
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
}
err = database.UpdateCommandStatus(commandID, "livre")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la validation forcée",
})
return
}
clientUsername, _ := command["username"].(string)
livreurAssign, _ := command["livreur_assign"].(string)
if clientUsername != "" {
clientMsg := fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
}
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
log.Printf("⚠️ Erreur compteur commandes: %v", err)
}
if err := database.AddClientPointsByCategory(clientUsername, 10, ""); err != nil {
log.Printf("⚠️ Erreur ajout points: %v", err)
}
if 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 != "",
})
}
+421
View File
@@ -0,0 +1,421 @@
// ============================================
// 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"
)
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
}
func validateReason(reason string) string {
if len(reason) > 500 {
reason = reason[:500]
}
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
}
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)
penalty, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
if err != nil {
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
if err.Error() == "confirmation requise" {
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)
hasETA := false
if livreurAssign != "" {
hasETA = database.CheckCommandETAExistsAndValid(commandID)
}
nextPenalty, _ := database.CalculateCancellationPenalty(username)
cancelCount, _ := database.GetClientCancellationsCount(username)
response := gin.H{
"success": false,
"warning": true,
"command_info": gin.H{
"command_id": commandID,
"status": currentStatus,
"livreur": livreurAssign,
},
}
if hasETA {
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,
"message": fmt.Sprintf(
"⚠️ ATTENTION: Une amende de %d sera appliquée pour annulation tardive",
nextPenalty,
),
"scale": gin.H{
"1st_cancel": 20,
"2nd_cancel": 50,
"3rd_cancel": 100,
"4th+_cancel": 150,
},
}
} else {
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_amount": penalty,
"warning": "Une amende a été appliquée pour annulation tardive",
}
} 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,
},
})
}
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
}
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]any
for _, order := range cancelledOrders {
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
items, _ := database.GetCommandItems(orderID)
logs, _ := database.GetCommandLogs(orderID)
var cancellationLog map[string]any
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
if status == "cancelled" {
cancellationLog = logEntry
break
}
}
cancelReason, _ := order["cancel_reason"].(string)
enrichedOrder := map[string]any{
"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),
"cancel_reason": cancelReason,
}
if cancellationLog != nil {
enrichedOrder["cancellation"] = gin.H{
"cancelled_at": cancellationLog["created_at"],
"cancelled_by": cancellationLog["author"],
"reason": cancelReason,
}
}
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),
},
})
}
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)
err = database.DeleteCommandAtomic(commandID, username, userRole)
if err != nil {
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
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,
},
})
}
+132
View File
@@ -0,0 +1,132 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func GetCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
categories, err := database.GetAllCategories()
if err != nil {
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"categories": categories,
})
}
func CreateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
return
}
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
return
}
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"category": category,
})
}
// PUT /api/v2/admin/protected/categories/:id — admin
func UpdateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
return
}
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
return
}
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
c.JSON(http.StatusOK, gin.H{
"success": true,
"category": category,
})
}
func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.DeleteCategory(id); err != nil {
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
if strings.Contains(err.Error(), "utilisée par") {
c.JSON(http.StatusConflict, gin.H{"error": "Catégorie utilisée par des produits existants"})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
}
+240
View File
@@ -0,0 +1,240 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetCommandStatus - Statut temps réel d'une commande
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
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
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
}
etaData, _ := database.GetCommandETA(commandID)
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"],
}
}
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
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",
})
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"],
"client_order_number": cmd["client_order_number"],
"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,
"proposed_address": cmd["proposed_address"],
"address_proposal_status": cmd["address_proposal_status"],
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
})
}
// GetCommandTracking - Suivi détaillé d'une commande
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
}
logs, _ := database.GetCommandLogs(commandID)
etaData, _ := database.GetCommandETA(commandID)
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,
})
}
func getStatusMessage(status string) string {
messages := map[string]string{
"pending": "⏳ En attente d'assignation",
"assigned": "✅ Livreur assigné",
"en_route": "🚗 En cours de livraison",
"arrived": "📍 Livreur arrivé",
"livre": "📦 Livré - En attente de confirmation",
"delivered": "✅ Livré",
"approved": "🎉 Livraison confirmée",
"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]any) []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": "👤",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
"approved": "🎉",
"cancelled": "🚫",
}
if icon, ok := icons[status]; ok {
return icon
}
return "📋"
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
package handlers
import (
"encoding/json"
"gestion/db"
"gestion/services"
"io"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// IPNWebhook - POST /api/v1/webhooks/nowpayments
func IPNWebhook(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
if !ok || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"})
return
}
sig := c.GetHeader("x-nowpayments-sig")
if !np.VerifyIPN(body, sig) {
log.Printf("[IPN] signature invalide")
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"})
return
}
var payload services.IPNPayload
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
return
}
payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String())
if err != nil || payment == nil {
log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String())
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
payAmount, _ := payload.PayAmount.Float64()
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de données"})
return
}
switch payload.PaymentStatus {
case "finished", "confirmed":
if err := database.ActivateCryptoCommand(payment.CommandID); err != nil {
log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err)
} else {
log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String())
}
case "failed", "expired":
if err := database.CancelCryptoCommand(payment.CommandID); err != nil {
log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err)
} else {
log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status
// Retourne le statut du paiement crypto d'une commande (polling côté client)
// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente
func GetCommandPaymentStatus(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
}
payment, err := database.GetCryptoPaymentByCommandID(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"})
return
}
if payment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"})
return
}
// Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence)
switch payment.Status {
case "waiting", "confirming", "confirmed", "sending":
np, npOk := c.Get("nowpayments")
if npOk && np != nil {
npClient := np.(*services.NowPaymentsClient)
npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID)
if err == nil && npStatus.PaymentStatus != payment.Status {
payAmount, _ := npStatus.PayAmount.Float64()
if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil {
payment.Status = npStatus.PaymentStatus
payment.PayAmount = payAmount
log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus)
}
switch npStatus.PaymentStatus {
case "finished", "confirmed":
_ = database.ActivateCryptoCommand(commandID)
case "failed", "expired":
_ = database.CancelCryptoCommand(commandID)
}
}
}
}
c.JSON(http.StatusOK, gin.H{
"command_id": payment.CommandID,
"payment_status": payment.Status,
"pay_address": payment.PayAddress,
"pay_amount": payment.PayAmount,
"pay_currency": payment.PayCurrency,
"price_amount": payment.PriceAmount,
"price_currency": payment.PriceCurrency,
})
}
+474
View File
@@ -0,0 +1,474 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/utils"
"log"
"net/http"
"slices"
"strconv"
"github.com/gin-gonic/gin"
)
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",
})
return
}
filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
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"],
"referral_used": command["referral_used"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
},
})
}
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",
})
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
}
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
}
validStatuses := []string{
"assigned",
"en_route",
"arrived",
"livre",
"cancelled",
}
if !slices.Contains(validStatuses, req.Status) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
if req.Status == "livre" {
if req.Latitude == 0 || req.Longitude == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"})
return
}
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
if destLat != 0 && destLon != 0 {
distance := utils.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",
"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",
})
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...")
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("📍 [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)
}
}
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
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)
if etaMinutes >= 60 {
h := etaMinutes / 60
m := etaMinutes % 60
if m > 0 {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
}
} else {
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
database.SetCommandETA(commandID, etaMinutes)
}
// 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 = utils.GetDeliveryStatusMessage(req.Status)
}
if etaMessage != "" {
message += fmt.Sprintf(" - %s", etaMessage)
}
database.AddCommandLog(commandID, req.Status, message, usernameStr)
// ✅ NOTIFICATION CLIENT
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
var clientMsg string
switch req.Status {
case "en_route":
notifETA := etaMinutes
if notifETA == 0 {
if etaData, err := database.GetCommandETA(commandID); err == nil {
if v, ok := etaData["total_eta_minutes"]; ok {
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
notifETA = n
}
}
}
}
if notifETA > 0 {
var etaStr string
if notifETA >= 60 {
h := notifETA / 60
m := notifETA % 60
if m > 0 {
etaStr = fmt.Sprintf("%dh%02d", h, m)
} else {
etaStr = fmt.Sprintf("%dh", h)
}
} else {
etaStr = fmt.Sprintf("%d min", notifETA)
}
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
} else {
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
}
case "arrived":
clientMsg = fmt.Sprintf("Descend, le livreur est là dans 3min (commande #%d) 🛵", database.GetClientOrderID(commandID))
case "livre":
clientMsg = fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
case "cancelled":
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", database.GetClientOrderID(commandID))
}
if clientMsg != "" {
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
}
}
// ✅ 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 "cancelled":
// Annulation par le livreur - Nettoyer la queue
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
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)
}
// POST /api/v1/livreur/deliveries/:id/issue
func ReportDeliveryIssue(c *gin.Context) {
username := c.GetString("username")
if c.GetString("role") != "livreur" {
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 invalide"})
return
}
var req struct {
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
return
}
validTypes := map[string]bool{
"client_absent": true,
"wrong_address": true,
"refused_delivery": true,
"no_access": true,
"other": true,
}
if !validTypes[req.IssueType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
return
}
database := c.MustGet("database").(*db.Database)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
return
}
if livreur, _ := command["livreur_assign"].(string); livreur != username {
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
return
}
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
if err != nil {
log.Printf("❌ [ISSUE] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
return
}
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
}
+542
View File
@@ -0,0 +1,542 @@
// ============================================
// handlers/delivery_admin_handlers.go
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"gestion/utils"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
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
}
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é",
})
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
}
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,
}
}
queueSize, _ := database.GetDeliverymanQueueSize(username)
currentCommand, _ := database.GetCurrentCommand(username)
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
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,
},
})
}
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, 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",
})
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)
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
}
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",
})
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,
})
}
// GetDeliveryPersonStats récupère les statistiques d'un livreur
func GetDeliveryPersonStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
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)
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,
},
})
}
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
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",
})
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,
})
}
// 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",
})
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",
})
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",
})
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 {
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,
})
}
+258
View File
@@ -0,0 +1,258 @@
// ============================================
// 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"
)
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
}
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
}
// Pour pending: aucune estimation disponible
if cmdStatus == "pending" {
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_available": false,
"message": "En attente d'assignation d'un livreur",
})
return
}
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID)
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 < 30*time.Second {
// 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"],
})
}
+838
View File
@@ -0,0 +1,838 @@
// ============================================
// 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
// ============================================
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"})
return
}
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
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,
})
}
// 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",
})
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",
})
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",
})
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",
})
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
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",
})
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",
})
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",
})
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",
})
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,
})
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",
})
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",
})
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",
})
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",
})
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",
})
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",
})
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",
})
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",
})
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",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"queue_info": queueInfo,
})
}
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"})
return
}
if !geoService.IsValidAddress(req.Address) {
c.JSON(http.StatusOK, gin.H{"valid": false, "message": "Adresse introuvable ou invalide"})
return
}
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,
})
}
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
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,
}
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
if err != nil {
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
}
+176
View File
@@ -0,0 +1,176 @@
// ============================================
// handlers/gps_handlers.go
// Gestion des liens GPS et visualisation
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"net/url"
"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",
"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",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"deliveryman": livreurAssign,
"navigation_links": links,
})
}
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
// GET /api/v1/livreur/deliveries/:id/nav-link
func GetLivreurNavLink(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
}
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 != username {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous est pas assignée"})
return
}
// Priorité : coordonnées GPS de la destination
var wazeLink string
destLat, hasLat := command["dest_latitude"].(float64)
destLon, hasLon := command["dest_longitude"].(float64)
if hasLat && hasLon && destLat != 0 && destLon != 0 {
wazeLink = fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", destLat, destLon)
log.Printf("🗺️ [NAV_LINK] Lien coords pour cmd %d: %s", commandID, wazeLink)
} else if adresse, _ := command["adresse"].(string); adresse != "" {
wazeLink = fmt.Sprintf("waze://?q=%s&navigate=yes", url.QueryEscape(adresse))
log.Printf("🗺️ [NAV_LINK] Lien adresse pour cmd %d: %s", commandID, wazeLink)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Aucune destination disponible pour cette commande"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"waze_app": wazeLink,
})
}
+254
View File
@@ -0,0 +1,254 @@
// ============================================
// handlers/history_handlers.go
// ============================================
// Gestion de l'historique des commandes terminées
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"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",
})
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)
// ✅ Récupérer les noms et clés des pools de points
poolNames := []string{"Pool 1", "Pool 2"}
var poolKeys []string
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
poolNames = make([]string, len(settings.PointsPools))
poolKeys = make([]string, len(settings.PointsPools))
for i, p := range settings.PointsPools {
poolNames[i] = p.Name
poolKeys[i] = p.Key
}
}
response := gin.H{
"success": true,
"commands": commands,
"count": len(commands),
}
if err == nil && client != nil {
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
poolPoints := make([]int, len(poolKeys))
for i, key := range poolKeys {
if key != "" {
poolPoints[i] = client.PointsExtra[key]
}
}
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
poolNames, poolKeys, poolPoints, client.PointsExtra)
response["client_stats"] = gin.H{
"username": client.Username,
"total_commands": client.Command,
"points_extra": client.PointsExtra,
"pool_points": poolPoints,
"pool_names": poolNames,
"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",
})
return
}
// ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]interface{}
for _, command := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
if commandID == 0 {
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_extra": client.PointsExtra,
"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),
})
}
+193
View File
@@ -0,0 +1,193 @@
package handlers
import (
"encoding/json"
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetClientNotifications retourne les notifications du client connecté
// GET /api/v1/notifications
func GetClientNotifications(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
// Récupérer toutes les notifications (max 50)
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
return
}
type Notification struct {
CommandID int `json:"command_id"`
Type string `json:"type"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
Read bool `json:"read"`
}
notifications := make([]Notification, 0, len(results))
unreadCount := 0
for _, raw := range results {
var n Notification
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
notifications = append(notifications, n)
if !n.Read {
unreadCount++
}
}
log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"unread_count": unreadCount,
"total": len(notifications),
})
}
// GetLivreurNotifications retourne les notifications du livreur connecté
// GET /api/v1/livreur/notifications
func GetLivreurNotifications(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
return
}
type Notification struct {
CommandID int `json:"command_id"`
Type string `json:"type"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
Read bool `json:"read"`
}
notifications := make([]Notification, 0, len(results))
unreadCount := 0
for _, raw := range results {
var n Notification
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
notifications = append(notifications, n)
if !n.Read {
unreadCount++
}
}
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"unread_count": unreadCount,
"total": len(notifications),
})
}
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
// POST /api/v1/livreur/notifications/read
func MarkLivreurNotificationsRead(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
if err != nil {
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
return
}
markedCount := 0
for i, raw := range results {
var n map[string]interface{}
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
if read, ok := n["read"].(bool); ok && read {
continue
}
n["read"] = true
updated, _ := json.Marshal(n)
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
markedCount++
}
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"marked_count": markedCount,
})
}
// MarkNotificationsRead marque toutes les notifications comme lues
// POST /api/v1/notifications/read
func MarkNotificationsRead(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
// Récupérer toutes les notifications
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
if err != nil {
log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
return
}
// Réécrire chaque notification avec read=true
markedCount := 0
for i, raw := range results {
var n map[string]interface{}
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
if read, ok := n["read"].(bool); ok && read {
continue
}
n["read"] = true
updated, _ := json.Marshal(n)
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
markedCount++
}
log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"marked_count": markedCount,
})
}
+622
View File
@@ -0,0 +1,622 @@
// ============================================
// handlers/basket_handlers_CORRIGES.go
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type BasketsRequest struct {
Username string `json:"username"`
ProductID int `json:"product_id"`
NameProduct string `json:"name_product"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
}
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req BasketsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
username, ok := c.Get("username")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
req.Username = username.(string)
if req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" {
stock, err := database.GetProductStockByID(req.ProductID)
if err != nil {
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
if stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
return
}
if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil {
log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err)
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
return
}
panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity)
if err != nil {
log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier})
return
}
}
// ============================================
// ============================================
// 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 {
utils.ServerErr(c, "Erreur lors de la récupération du panier", err)
return
}
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
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 {
utils.BindErr(c, err)
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
itemUsername, err := database.GetBasketItemOwner(req.ID)
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 {
utils.ServerErr(c, "Erreur lors de la suppression", err)
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 {
utils.ServerErr(c, "Erreur lors du vidage du panier", err)
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),
})
}
// POST /api/v1/checkout
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"`
UseReferralBalance bool `json:"use_referral_balance"`
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
return
}
cmd := &models.Command{DeliveryAddress: req.DeliveryAddress}
if err := database.CheckAddress(cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress})
return
}
req.DeliveryAddress = cmd.DeliveryAddress
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
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 {
utils.ServerErr(c, "Impossible de récupérer le panier", err)
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))
// ============================================
// 1️⃣b Vérifier le minimum de commande selon la zone
// ============================================
var cartTotal float64
for _, item := range items {
if price, ok := item["price"].(float64); ok {
cartTotal += price
}
}
// Récupérer les paramètres globaux (zones + parrainage)
appSettings, _ := database.GetSettings()
// Récupérer le solde parrainage disponible (seulement si le système est activé)
var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
if !zoneResult.OK {
if zoneResult.ZoneName == "inconnue" {
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse invalide : aucun code postal détecté",
})
} else if zoneResult.ZoneName == "hors zone" {
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Livraison non disponible pour ce code postal",
"postal_code": zoneResult.PostalCode,
})
} else {
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"referral_balance": referralBalance,
})
}
return
}
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance
if effectivePayment < zoneResult.MinAmount {
needed := zoneResult.MinAmount + referralBalance
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": needed,
"cart_total": cartTotal,
"missing": needed - cartTotal,
"referral_balance": referralBalance,
"postal_code": zoneResult.PostalCode,
})
return
}
referralUsed = referralBalance
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
// ============================================
// 2️⃣ Débiter le parrainage AVANT la commande (évite double-spend)
// ============================================
if referralUsed > 0 {
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"})
return
}
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
}
// Vérification option crypto
isCrypto := req.PaymentMethod == "crypto"
if isCrypto {
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
if !npOk || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
return
}
if req.PayCurrency == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"})
return
}
}
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
if err != nil {
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
return
}
commandID := command.ID
if referralUsed > 0 {
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
}
}
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// ============================================
// PAIEMENT CRYPTO - créer le paiement NowPayments
// ============================================
if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
payReq := &services.CreatePaymentRequest{
PriceAmount: cartTotal,
PriceCurrency: "eur",
PayCurrency: req.PayCurrency,
OrderID: fmt.Sprintf("%d", commandID),
IPNCallbackURL: ipnURL,
}
payResp, err := np.CreatePayment(payReq)
if err != nil {
// Annuler la commande et restaurer le panier / parrainage
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"})
return
}
// Passer la commande en 'pending_payment' (attente confirmation)
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
}
priceAmt, _ := payResp.PriceAmount.Float64()
payAmt, _ := payResp.PayAmount.Float64()
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"command_id": commandID,
"payment_method": "crypto",
"payment_status": payResp.Status,
"pay_address": payResp.PayAddress,
"pay_amount": payAmt,
"pay_currency": payResp.PayCurrency,
"price_amount": priceAmt,
"price_currency": payResp.PriceCurrency,
"message": "Commande créée - En attente de paiement crypto",
})
return
}
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
// ============================================
// 3️⃣ Vider le panier (sans restituer le stock — déjà déduit à l'ajout)
// ============================================
err = database.ClearBasketOnCheckout(usernameStr)
if err != nil {
utils.ServerErr(c, "Impossible de vider le panier", err)
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)
usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID)
if errEligible == nil && len(usernames) > 0 {
log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames))
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)
}
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
// Notifier le client
clientOrderID := database.GetClientOrderID(commandID)
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
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 éligible disponible")
}
} else {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
}
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{
"success": true,
"command_id": commandID,
"client_order_number": command.ClientOrderID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
"referral_used": referralUsed,
"referral_balance": newBalance,
}
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.StatusCreated, resp)
}
// getBaseURL construit l'URL de base depuis la requête en cours
func getBaseURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
+166
View File
@@ -0,0 +1,166 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
func SetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Parrain string `json:"parrain" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Champ parrain requis"})
return
}
if req.Parrain == targetUsername {
c.JSON(http.StatusBadRequest, gin.H{"error": "Un client ne peut pas être son propre parrain"})
return
}
// Vérifier que le parrain existe
parrain, err := database.GetClientByUsername(req.Parrain)
if err != nil || parrain == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
return
}
// Vérifier que le client n'a pas déjà un parrain
existing, err := database.GetClientParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur vérification parrain", err)
return
}
if existing != "" {
c.JSON(http.StatusConflict, gin.H{"error": "Ce client a déjà un parrain", "parrain": existing})
return
}
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
utils.ServerErr(c, "Erreur enregistrement parrain", err)
return
}
settings, _ := database.GetSettings()
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
} else {
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Parrain enregistré",
"client": targetUsername,
"parrain": req.Parrain,
"amount_credited": settings.ReferralAmount,
})
}
// GetMyParrainInfo — GET /api/v1/parrain (client authentifié)
// Retourne le parrain du client + ses filleuls + son solde parrainage.
func GetMyParrainInfo(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
clientUsername := username.(string)
settings, _ := database.GetSettings()
parrain, err := database.GetClientParrain(clientUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération parrain", err)
return
}
filleuls, err := database.GetClientsByParrain(clientUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération filleuls", err)
return
}
balance, err := database.GetClientReferralBalance(clientUsername)
if err != nil {
balance = 0
}
filleulNames := make([]string, 0, len(filleuls))
for _, f := range filleuls {
filleulNames = append(filleulNames, f.Username)
}
c.JSON(http.StatusOK, gin.H{
"parrain": parrain,
"filleuls": filleulNames,
"filleul_count": len(filleulNames),
"referral_balance": balance,
"referral_enabled": settings.ReferralEnabled,
"referral_amount": settings.ReferralAmount,
})
}
// GetClientParrainAdmin — GET /api/v2/admin/protected/client/:username/parrain (admin)
// Retourne les infos parrainage d'un client spécifique.
func GetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
parrain, err := database.GetClientParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération parrain", err)
return
}
filleuls, err := database.GetClientsByParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération filleuls", err)
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
filleulNames := make([]string, 0, len(filleuls))
for _, f := range filleuls {
filleulNames = append(filleulNames, f.Username)
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"parrain": parrain,
"filleuls": filleulNames,
"filleul_count": len(filleulNames),
"referral_balance": balance,
})
}
// GetParrainStatsAdmin — GET /api/v2/admin/protected/parrain/stats (admin)
// Retourne la liste de tous les parrains avec leur nombre de filleuls et leur solde.
func GetParrainStatsAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
stats, err := database.GetParrainStats()
if err != nil {
utils.ServerErr(c, "Erreur récupération stats parrainage", err)
return
}
c.JSON(http.StatusOK, gin.H{
"parrains": stats,
"total": len(stats),
})
}
+949
View File
@@ -0,0 +1,949 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gabriel-vasile/mimetype"
"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 float64, 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 validateUnit(unit string) error {
validUnits := map[string]bool{
"u": true, // unité
"kg": true, // kilogramme
"g": true, // gramme
"bag": true, // sac
"l": true, // litre
"cl": true, // centilitre
"pcs": true, // pièces
}
if !validUnits[unit] {
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
}
return nil
}
func validateCategory(database *db.Database, category string) error {
if category == "" {
return fmt.Errorf("catégorie requise")
}
exists, err := database.CategoryExists(category)
if err != nil {
return fmt.Errorf("erreur vérification catégorie")
}
if !exists {
return fmt.Errorf("catégorie invalide")
}
return nil
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open()
if err != nil {
return "", err
}
defer file.Close()
mtype, err := mimetype.DetectReader(file)
if err != nil {
return "", err
}
mimeType := mtype.String()
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
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")
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
if unit == "" {
unit = "u"
}
// ✅ 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(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateUnit(unit); 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.ParseFloat(quantityStr, 64)
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,
Unit: unit,
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(database, 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"`
Unit string `json:"unit"`
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(database, updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if updateData.Unit == "" {
updateData.Unit = "u"
}
if err := validateUnit(updateData.Unit); 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)
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, updateData.Stock, updateData.Prices); err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
// ✅ 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,
})
}
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é",
})
}
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
detectedMime, err := validateFileMimeType(file)
if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return
}
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return
}
if fileType == "video" && !strings.HasPrefix(detectedMime, "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
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
func GetMyReferralBalance(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
settings, _ := database.GetSettings()
if !settings.ReferralEnabled {
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
return
}
balance, err := database.GetClientReferralBalance(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Solde de parrainage introuvable"})
return
}
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
}
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
func CreditClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Amount float64 `json:"amount" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
return
}
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
utils.ServerErr(c, "Impossible de créditer le solde", err)
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage crédité",
"balance": balance,
})
}
// ResetClientReferralAdmin — DELETE /api/v2/admin/protected/client/:username/referral/reset (admin)
func ResetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
if err := database.ResetClientReferralBalance(targetUsername); err != nil {
utils.ServerErr(c, "Impossible de réinitialiser le solde", err)
return
}
log.Printf("✅ [REFERRAL] Solde parrainage remis à zéro pour %s", targetUsername)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage réinitialisé",
"balance": 0,
})
}
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
func GetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
balance, err := database.GetClientReferralBalance(targetUsername)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client introuvable"})
return
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"balance": balance,
})
}
+99
View File
@@ -0,0 +1,99 @@
package handlers
import (
"gestion/db"
"gestion/models"
"gestion/services"
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
// GET /api/v1/app-settings — public, sans auth
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
func GetPublicSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
// En cas d'erreur, retourner les valeurs par défaut
settings = db.DefaultSettings()
}
poolNames := make([]string, len(settings.PointsPools))
poolKeys := make([]string, len(settings.PointsPools))
for i, p := range settings.PointsPools {
poolNames[i] = p.Name
poolKeys[i] = p.Key
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"penalties_enabled": settings.PenaltiesEnabled,
"show_amende_score": settings.ShowAmendeScore,
"points_enabled": settings.PointsEnabled,
"points_separated": len(settings.PointsPools) > 1,
"pool_names": poolNames,
"pool_keys": poolKeys,
"referral_enabled": settings.ReferralEnabled,
"referral_amount": settings.ReferralAmount,
"delivery_schedule": settings.DeliverySchedule,
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
"crypto_only": settings.CryptoOnly,
"nowpayments_currencies": settings.NowPaymentsCurrencies,
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
"shop_name": settings.ShopName,
"two_fa_enabled": settings.Telegram2FAEnabled,
})
}
// GET /api/v2/admin/protected/settings
func GetSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
}
// PUT /api/v2/admin/protected/settings
func UpdateSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
return
}
if err := database.UpdateSettings(req); err != nil {
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
return
}
// Recharger le service Telegram si le token/username a changé
if services.TelegramBot != nil {
services.TelegramBot.Reload(req.TelegramBotToken, req.TelegramBotUsername)
services.TelegramBot.SetNotificationsEnabled(req.TelegramNotificationsEnabled)
if req.TelegramBotToken != "" {
log.Printf("✅ [SETTINGS] Service Telegram rechargé (username: %s)", req.TelegramBotUsername)
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
} else {
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL)
}
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
}
+299
View File
@@ -0,0 +1,299 @@
package handlers
import (
"gestion/db"
"gestion/models"
"gestion/services"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func TelegramWebhook(c *gin.Context) {
// Vérification du secret webhook
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
if services.TelegramBot == nil || !services.TelegramBot.ValidateWebhookSecret(secret) {
c.Status(http.StatusUnauthorized)
return
}
var update models.TgUpdate
if err := c.ShouldBindJSON(&update); err != nil {
c.Status(http.StatusBadRequest)
return
}
if update.Message == nil {
c.Status(http.StatusOK)
return
}
text := strings.TrimSpace(update.Message.Text)
chatID := update.Message.Chat.ID
// Commande /start <token> — liaison de compte
if token, ok := strings.CutPrefix(text, "/start "); ok {
token = strings.TrimSpace(token)
handleLinkAccount(c, token, chatID)
return
}
// Commande /start sans token — message d'accueil
if text == "/start" {
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID,
"👋 <b>Bienvenue !</b>\n\nPour lier votre compte, générez un token depuis l'application et envoyez <code>/start &lt;token&gt;</code>.")
}
}
c.Status(http.StatusOK)
}
func handleLinkAccount(c *gin.Context, token string, chatID int64) {
if token == "" {
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Token manquant. Générez un nouveau token depuis l'application.")
}
c.Status(http.StatusOK)
return
}
database := c.MustGet("database").(*db.Database)
username, role, err := db.ValidateAndConsumeLinkToken(token)
if err != nil {
log.Printf("⚠️ [TELEGRAM_LINK] Token invalide: %v", err)
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Token invalide ou expiré. Générez un nouveau token depuis l'application.")
}
c.Status(http.StatusOK)
return
}
// Enregistrer le chat_id selon le rôle
var saveErr error
switch role {
case "client":
saveErr = database.SaveClientTelegramChatID(username, chatID)
default:
saveErr = database.SaveUserTelegramChatID(username, chatID)
}
if saveErr != nil {
log.Printf("❌ [TELEGRAM_LINK] Erreur sauvegarde chat_id pour %s (%s): %v", username, role, saveErr)
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Une erreur est survenue. Réessayez.")
}
c.Status(http.StatusOK)
return
}
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
}
c.Status(http.StatusOK)
}
func GenerateClientLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
token, err := db.GenerateLinkToken(username, "client")
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
func GenerateLivreurLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
token, err := db.GenerateLinkToken(username, "livreur")
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour livreur %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
func GenerateAdminLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
// Récupérer le rôle réel depuis la DB
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByUsername(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Utilisateur introuvable"})
return
}
token, err := db.GenerateLinkToken(username, user.Role)
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour admin %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
// GET /api/v1/telegram/status
func GetClientTelegramStatus(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
_, linked, err := database.GetClientTelegramChatID(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
return
}
c.JSON(http.StatusOK, gin.H{
"linked": linked,
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
})
}
// GET /api/v1/livreur/telegram/status
func GetLivreurTelegramStatus(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
_, linked, err := database.GetUserTelegramChatID(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
return
}
c.JSON(http.StatusOK, gin.H{
"linked": linked,
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
})
}
// ============================================
// DÉLIAISON TELEGRAM
// ============================================
// DELETE /api/v1/telegram/unlink
func UnlinkClientTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
// Désactiver la 2FA si Telegram est délié
if clientID > 0 {
_ = database.SetClientTwoFAEnabled(clientID, false)
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DELETE /api/v1/livreur/telegram/unlink
func UnlinkLivreurTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte livreur %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DELETE /api/v2/admin/protected/telegram/unlink
func UnlinkAdminTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
+52
View File
@@ -0,0 +1,52 @@
// ============================================
// handlers/traffic_handlers.go - COMPLET
// ============================================
package handlers
import (
"encoding/json"
"strconv"
)
// 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
}
}
+494
View File
@@ -0,0 +1,494 @@
// ============================================
// handlers/profile_handlers.go - VERSION CORRIGÉE
// ============================================
// Gestion des modifications de profils
package handlers
import (
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gestion/db"
"gestion/models"
"gestion/utils"
)
// ============================================
// 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",
})
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 = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas (clients et users)
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
}
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username réservé: %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 = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.Nom {
if len(req.Nom) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
return
}
client.Nom = req.Nom
hasChanges = true
}
// Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.Prenom {
if len(req.Prenom) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
return
}
client.Prenom = req.Prenom
hasChanges = true
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !utils.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 := utils.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),
})
}
// GetMyProfile retourne le profil du client connecté
// GET /api/v1/profile
func GetMyProfile(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
}
client, err := database.GetClientByUsername(username.(string))
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "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("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",
})
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, Amende: %.2f",
client.Command, client.Amende)
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.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 (opération séparée pour garantir l'écriture)
var newHashedPassword string
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
}
newHashedPassword = string(hashed)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
}
// Mise à jour du nom
if req.Nom != "" {
req.Nom = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.Nom {
client.Nom = req.Nom
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
}
// Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.Prenom {
client.Prenom = 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 !utils.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 := utils.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)
}
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)
}
if req.Amende != nil {
if *req.Amende < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
return
}
if *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.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Amende: %.2f",
client.Command, client.Amende)
// Sauvegarder les modifications de profil (hors mot de passe)
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour profil: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
// Mettre à jour le mot de passe séparément si demandé
if newHashedPassword != "" {
if err := database.UpdateClientPasswordAndClearFlag(clientID, newHashedPassword); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour mot de passe: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour du mot de passe"})
return
}
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Mot de passe mis à jour pour client ID=%d", clientID)
}
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)
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès réservé aux administrateurs",
})
return
}
// 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",
})
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 = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != user.Username {
// Vérifier que le nouveau username n'existe pas (users et clients)
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
}
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username réservé: %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,
"points_extra": client.PointsExtra,
"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,408 @@
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",
})
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: Coordonnées GPS reçues et valides
log.Printf("📍 [VALIDATE_LIVREUR] GPS reçu: (%.6f, %.6f)", req.Latitude, req.Longitude)
// É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",
})
return
}
// ✅ ÉTAPE 6: Ajouter un log
database.AddCommandLog(commandID, "livre",
fmt.Sprintf("Livraison confirmée par livreur - GPS: (%.6f, %.6f)", req.Latitude, req.Longitude),
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",
"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",
})
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 != "assigned" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de démarrer cette livraison",
"current_status": currentStatus,
"message": "La commande doit être en statut '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",
})
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)
// Notifier le client
if clientUsername, _ := command["username"].(string); clientUsername != "" {
var msg string
etaMinutes := 0
if etaData, err := database.GetCommandETA(commandID); err == nil {
if v, ok := etaData["total_eta_minutes"]; ok {
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
etaMinutes = n
}
}
}
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
}
}
if etaMinutes > 0 {
var etaStr string
if etaMinutes >= 60 {
h := etaMinutes / 60
m := etaMinutes % 60
if m > 0 {
etaStr = fmt.Sprintf("%dh%02d", h, m)
} else {
etaStr = fmt.Sprintf("%dh", h)
}
} else {
etaStr = fmt.Sprintf("%d min", etaMinutes)
}
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
} else {
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
}
database.NotifyClient(clientUsername, commandID, "en_route", msg)
}
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
}
+59
View File
@@ -0,0 +1,59 @@
package handlers
import (
"gestion/models"
"regexp"
)
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
// postalSet convertit une slice de codes en set pour lookup O(1).
func postalSet(codes []string) map[string]struct{} {
m := make(map[string]struct{}, len(codes))
for _, c := range codes {
m[c] = struct{}{}
}
return m
}
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
func extractPostalCode(address string) string {
m := postalCodeRe.FindStringSubmatch(address)
if len(m) < 2 {
return ""
}
return m[1]
}
// zoneCheckResult est le résultat de la vérification de zone.
type zoneCheckResult struct {
PostalCode string
ZoneName string
MinAmount float64
OK bool
}
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
// Les zones sont lues depuis la DB (settings.PostalZones).
// Code postal introuvable → OK = false (refus).
// Code postal hors de toutes les zones → OK = false (refus).
func checkDeliveryZone(deliveryAddress string, total float64, zones []models.PostalZone) zoneCheckResult {
code := extractPostalCode(deliveryAddress)
if code == "" {
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
}
for _, zone := range zones {
set := postalSet(zone.Codes)
if _, found := set[code]; found {
return zoneCheckResult{
PostalCode: code,
ZoneName: zone.Name,
MinAmount: zone.MinAmount,
OK: total >= zone.MinAmount,
}
}
}
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
}
+152
View File
@@ -0,0 +1,152 @@
// ============================================
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
// ============================================
package main
import (
"gestion/db"
"gestion/routes"
"gestion/services"
"gestion/workers"
"log"
"net/http"
"os"
"time"
"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() {
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
time.Local = loc
} else {
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
}
if err := godotenv.Load(); err != nil {
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
}
database := db.InitDB()
defer database.Close()
log.Printf("✅ Database initialisée: %+v", database)
db.InitRedis()
defer db.Redis.Close()
log.Println("✅ Redis initialisé avec succès")
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
log.Println("✅ Service de géolocalisation initialisé")
telegramService := services.NewTelegramService()
if telegramService.IsConfigured() {
log.Println("✅ Service Telegram initialisé")
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := telegramService.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook: %v", err)
}
}
} else {
log.Println("️ Service Telegram désactivé (TELEGRAM_BOT_TOKEN non défini)")
}
database.MigrateAddTelegramColumns()
if dbSettings, err := database.GetSettings(); err == nil {
telegramService.Reload(dbSettings.TelegramBotToken, dbSettings.TelegramBotUsername)
telegramService.SetNotificationsEnabled(dbSettings.TelegramNotificationsEnabled)
if dbSettings.TelegramBotToken != "" {
log.Printf("✅ [TELEGRAM] Config chargée depuis la DB (username: %s)", dbSettings.TelegramBotUsername)
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := telegramService.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook (DB reload): %v", err)
}
}
}
}
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")
}
}
go database.StartQueueCleanupScheduler()
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
if err := database.SyncAllDeliverymanStatuses(); err != nil {
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
} else {
log.Println("✅ Synchronisation des statuts livreurs terminée")
}
go workers.StartAutoAssignmentCron(database, geoService)
log.Println("✅ Cron job auto-assignation démarré (5 min)")
go workers.StartRedisWorkers(database)
log.Println("✅ Workers Redis démarrés")
workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient {
s, err := database.GetSettings()
if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" {
return nil
}
return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret)
}, 2*time.Minute)
log.Println("✅ Worker paiements crypto démarré (2 min)")
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
store.Options(sessions.Options{
Path: "/",
Domain: "",
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
r.Use(sessions.Sessions("mysession", store))
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://uber-stup.club", "https://5.181.0.112.nip.io", "https://5.181.0.112.nip.io:8080", "https://5.181.0.112.nip.io:8443", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
r.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
c.Next()
})
r.Use(func(c *gin.Context) {
settings, err := database.GetSettings()
if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" {
np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret)
c.Set("nowpayments", np)
}
c.Next()
})
r.Static("/uploads", "./uploads")
routes.SetupRoutes(r, database, geoService)
if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
}
}
@@ -0,0 +1,72 @@
package middleware
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func BlockClientIfPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
c.Next()
return
}
clientID, exists := c.Get("client_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
if id, ok := clientID.(int); ok {
if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": session.PenaltyCache,
"blocked": true,
})
c.Abort()
return
}
}
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
usernameStr, ok := username.(string)
if !ok || usernameStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
c.Abort()
return
}
amende, err := database.GetClientAmende(usernameStr)
if err != nil {
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
c.Next()
return
}
if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": amende,
"blocked": true,
})
c.Abort()
return
}
c.Next()
}
@@ -0,0 +1,88 @@
package middleware
import (
"fmt"
"gestion/db"
"gestion/models"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func OrderHoursMiddleware(c *gin.Context) {
loc, err := time.LoadLocation("Europe/Paris")
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
weekday := now.Weekday()
hour := now.Hour()
min := now.Minute()
// Récupérer le planning depuis les settings DB
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
c.Next()
return
}
sched := settings.DeliverySchedule
var day models.DaySchedule
switch weekday {
case time.Monday:
day = sched.Monday
case time.Tuesday:
day = sched.Tuesday
case time.Wednesday:
day = sched.Wednesday
case time.Thursday:
day = sched.Thursday
case time.Friday:
day = sched.Friday
case time.Saturday:
day = sched.Saturday
case time.Sunday:
day = sched.Sunday
}
if !day.Enabled {
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles aujourd'hui",
"message": "La livraison n'est pas disponible ce jour",
"current_time": now.Format("15:04"),
})
c.Abort()
return
}
parseTime := func(t string) int {
var h, m int
fmt.Sscanf(t, "%d:%d", &h, &m)
return h*60 + m
}
currentMinutes := hour*60 + min
openMinutes := parseTime(day.OpenTime)
closeMinutes := parseTime(day.CloseTime)
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles à cette heure",
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
"current_time": now.Format("15:04"),
"open_at": day.OpenTime,
"close_at": day.CloseTime,
})
c.Abort()
return
}
log.Printf("✅ [CLOCK-MWARE] Commande autorisée à %02d:%02d", hour, min)
c.Next()
}
@@ -0,0 +1,600 @@
// ============================================
// 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
)
// ============================================
// 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) (any, 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...")
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (any, 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
}
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
}
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
}
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()
}
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
}
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 — admin uniquement
if claims.Role != "admin" {
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s (admin requis)", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
c.Abort()
return
}
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()
}
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 rôle — admin ou cabine uniquement
if claims.Role != "admin" && claims.Role != "cabine" {
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès cabine requis"})
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()
}
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 rôle — admin ou livreur uniquement
if claims.Role != "admin" && claims.Role != "livreur" {
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès livreur requis"})
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()
}
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()
}
func RateLimitMiddleware(c *gin.Context) {
// Récupérer le client_id
clientID, ok := c.Get("client_id")
if !ok {
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
}
if count == 1 {
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
}
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
}
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
c.Next()
}
// LoginRateLimitMiddleware limite les tentatives de connexion par IP.
// Config: 10 tentatives par 15 minutes.
func LoginRateLimitMiddleware(c *gin.Context) {
ip := c.ClientIP()
rateLimitKey := "ratelimit:login:" + ip
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
if err != nil {
log.Printf("⚠️ [LOGIN-RATELIMIT] Erreur Redis: %v", err)
c.Next()
return
}
if count == 1 {
db.Redis.Expire(db.RedisCtx, rateLimitKey, 15*time.Minute)
}
if count > 10 {
log.Printf("❌ [LOGIN-RATELIMIT] IP %s bloquée: %d tentatives/15min", ip, count)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop de tentatives de connexion - Réessayez dans 15 minutes",
})
c.Abort()
return
}
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
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
}
func DatabaseMiddleware(db *db.Database) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("database", db)
c.Next()
}
}
+13
View File
@@ -0,0 +1,13 @@
package models
import "time"
type Address struct {
ID int64 `db:"id" gorm:"primaryKey;autoIncrement"`
InvalidAddress string `db:"invalid_address" gorm:"column:invalid_address"`
CorrectAddress string `db:"correct_address" gorm:"column:correct_address"`
CreatedAt time.Time `db:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `db:"updated_at" gorm:"autoUpdateTime"`
}
func (Address) TableName() string { return "adresse_correction" }
+14
View File
@@ -0,0 +1,14 @@
package models
import "time"
type AlertPolicy struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username" gorm:"column:username"`
Status string `json:"status" gorm:"column:status"`
Message string `json:"message" gorm:"column:message"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (AlertPolicy) TableName() string { return "alerte_policy" }
+50
View File
@@ -0,0 +1,50 @@
package models
import "github.com/golang-jwt/jwt/v5"
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
}
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 any `json:"user"`
}
type ProfileResponse struct {
Username string `json:"username"`
Role string `json:"role"`
}
+27
View File
@@ -0,0 +1,27 @@
// models/client.go
package models
import "time"
type Client struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;not null" json:"username"`
Password string `gorm:"column:password" json:"-"`
Nom string `gorm:"column:nom" json:"nom"`
Prenom string `gorm:"column:prenom" json:"prenom"`
Telephone string `gorm:"column:telephone;not null" json:"telephone"`
Command int `gorm:"column:command;default:0" json:"command"`
CancelCommande int `gorm:"column:cancel_commande;default:0" json:"cancel_commande"`
Amende float64 `gorm:"column:amende;default:0" json:"amende"`
CancellationsCount int `gorm:"column:cancellations_count;not null;default:0" json:"cancellations_count"`
LastPenaltyReason string `gorm:"column:last_penalty_reason" json:"last_penalty_reason"`
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
PointsExtra map[string]int `gorm:"-" json:"points_extra"`
Parrain string `gorm:"column:parrain" json:"parrain"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled;default:false" json:"two_fa_enabled"`
}
func (Client) TableName() string { return "clients" }
+54
View File
@@ -0,0 +1,54 @@
package models
import "time"
type Command struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
ClientOrderID int `gorm:"column:client_order_id" json:"client_order_id"`
UserID int `gorm:"column:user_id" json:"user_id"`
Username string `gorm:"column:username" json:"username"`
Status string `gorm:"column:status" json:"status"`
Total float64 `gorm:"column:total_prix" json:"total"`
DeliveryAddress string `gorm:"column:adresse" json:"delivery_address"`
LivreurAssign string `gorm:"column:livreur_assign" json:"livreur_assign,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (Command) TableName() string { return "commandes" }
// CommandItem représente un produit dans une commande
type CommandItem struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Produit string `gorm:"column:produit" json:"produit"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
type CommandLog struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
CommandID int `gorm:"column:command_id" json:"command_id"`
Status string `gorm:"column:status" json:"status"`
Message string `gorm:"column:message" json:"message"`
Author string `gorm:"column:author" json:"author"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (CommandLog) TableName() string { return "command_logs" }
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"`
}
+25
View File
@@ -0,0 +1,25 @@
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" gorm:"primaryKey;autoIncrement"`
CommandID int `json:"command_id" gorm:"column:command_id;index"`
IssueType string `json:"issue_type" gorm:"column:issue_type"`
Description string `json:"description" gorm:"column:description"`
Status string `json:"status" gorm:"column:status"`
ReportedBy string `json:"reported_by" gorm:"column:reported_by"`
ResolvedBy string `json:"resolved_by,omitempty" gorm:"column:resolved_by"`
Resolution string `json:"resolution,omitempty" gorm:"column:resolution"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (DeliveryIssue) TableName() string { return "delivery_issues" }
+19
View File
@@ -0,0 +1,19 @@
package models
import "time"
type Media struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
ProductID int `gorm:"column:product_id" json:"product_id"`
Type string `gorm:"column:type" json:"type"`
URL string `gorm:"column:url" json:"url"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (Media) TableName() string { return "media" }
// Méthodes pour l'interface Database
func (m *Media) GetProductID() int { return m.ProductID }
func (m *Media) GetType() string { return m.Type }
func (m *Media) GetURL() string { return m.URL }
func (m *Media) SetID(id int) { m.ID = id }
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
type Panier struct {
ID int `json:"id"`
Username string `json:"username"`
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
+20
View File
@@ -0,0 +1,20 @@
package models
import "time"
// CryptoPayment suit un paiement crypto NowPayments lié à une commande
type CryptoPayment struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
CommandID int `json:"command_id" gorm:"column:command_id;index"`
NowPaymentID string `json:"nowpayment_id" gorm:"column:nowpayment_id;not null"`
Status string `json:"status" gorm:"column:status"`
PriceAmount float64 `json:"price_amount" gorm:"column:price_amount"`
PriceCurrency string `json:"price_currency" gorm:"column:price_currency"`
PayCurrency string `json:"pay_currency" gorm:"column:pay_currency"`
PayAddress string `json:"pay_address" gorm:"column:pay_address"`
PayAmount float64 `json:"pay_amount" gorm:"column:pay_amount"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (CryptoPayment) TableName() string { return "crypto_payments" }
+51
View File
@@ -0,0 +1,51 @@
package models
import "time"
type Product struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"column:name" binding:"required"`
Category string `json:"category" gorm:"column:category" binding:"required"`
Description string `json:"description" gorm:"column:description"`
Stock float64 `json:"stock" gorm:"column:stock"`
Unit string `json:"unit" gorm:"column:unit"`
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (Product) TableName() string { return "products" }
type ProductPrice struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
ProductID int `json:"product_id" gorm:"column:product_id;index"`
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
Price float64 `json:"price" gorm:"column:price" binding:"required"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func (ProductPrice) TableName() string { return "product_prices" }
type StockInfo struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
Reserved float64 `json:"reserved"`
Available float64 `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 }
func (p *Product) GetUnit() string { return p.Unit }
func (p *Product) GetPrices() []ProductPrice { return p.Prices }
func (p *Product) SetID(id int) { p.ID = id }
func (p *Product) SetCreatedAt(t time.Time) { p.CreatedAt = t }
func (p *Product) SetUpdatedAt(t time.Time) { p.UpdatedAt = t }
+29
View File
@@ -0,0 +1,29 @@
package models
import "time"
type CommandQueue struct {
CommandID int
Username string
TotalPrice float64
Address string
Lat float64 // ajouter
Lng float64 // ajouter
CreatedAt time.Time
EstimatedETA int
}
type DeliveryPersonStatus struct {
Username string `json:"username"`
Status string `json:"status"` // "available", "busy", "offline"
CurrentCommand int `json:"current_command,omitempty"`
LastUpdate time.Time `json:"last_update"`
}
type StockReservation struct {
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
Username string `json:"username"`
ExpiresAt time.Time `json:"expires_at"`
CommandID int `json:"command_id"`
}
+84
View File
@@ -0,0 +1,84 @@
package models
type PostalZone struct {
Name string `json:"name"`
MinAmount float64 `json:"min_amount"`
Codes []string `json:"codes"`
}
// PointsTier représente un palier du barème de points
// Si Max == 0, il n'y a pas de borne supérieure (illimité)
type PointsTier struct {
Min float64 `json:"min"`
Max float64 `json:"max"` // 0 = illimité
Points int `json:"points"`
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct {
Enabled bool `json:"enabled"`
OpenTime string `json:"open_time"` // ex: "09:00"
CloseTime string `json:"close_time"` // ex: "20:00"
}
// DeliverySchedule représente les horaires de livraison pour chaque jour
type DeliverySchedule struct {
Monday DaySchedule `json:"monday"`
Tuesday DaySchedule `json:"tuesday"`
Wednesday DaySchedule `json:"wednesday"`
Thursday DaySchedule `json:"thursday"`
Friday DaySchedule `json:"friday"`
Saturday DaySchedule `json:"saturday"`
Sunday DaySchedule `json:"sunday"`
}
type PointsPool struct {
Key string `json:"key"` // identifiant interne (ex: "pool_0")
Name string `json:"name"` // nom affiché (ex: "Cannabis", "Accessoires")
Categories []string `json:"categories"` // catégories de produits assignées à ce pool
Tiers []PointsTier `json:"tiers"` // barème de points
}
// PenaltyTier représente un palier d'amende : à partir de MinCancel annulations, Amount est appliqué
type PenaltyTier struct {
MinCancel int `json:"min_cancel"` // nombre d'annulations à partir duquel ce palier s'applique
Amount int `json:"amount"` // montant de l'amende
}
// CategoryRoute associe un livreur à une ou plusieurs catégories de produits
type CategoryRoute struct {
DeliverymanUsername string `json:"deliveryman_username"`
Categories []string `json:"categories"`
}
// DeliveryModeConfig configure le mode d'assignation des livreurs
// Mode "single" → un seul livreur, toutes catégories confondues
// Mode "category_based" → chaque livreur gère ses catégories dédiées
type DeliveryModeConfig struct {
Mode string `json:"mode"` // "single" | "category_based"
CategoryRoutes []CategoryRoute `json:"category_routes"` // utilisé uniquement en mode category_based
}
// AppSettings contient les paramètres globaux de l'application
type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather)
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
}
+21
View File
@@ -0,0 +1,21 @@
package models
type TgUpdate struct {
UpdateID int `json:"update_id"`
Message *TgMessage `json:"message"`
}
type TgMessage struct {
MessageID int `json:"message_id"`
Chat TgChat `json:"chat"`
Text string `json:"text"`
}
type TgChat struct {
ID int64 `json:"id"`
}
type TelegramLinkData struct {
Username string `json:"username"`
Role string `json:"role"` // "client" | "livreur" | "admin" | "cabine"
}
+40
View File
@@ -0,0 +1,40 @@
package models
type Incident struct {
Type string `json:"type"`
Icon int `json:"iconCategory"`
Description string `json:"description"`
}
type IncidentResponse struct {
Incidents []struct {
Type string `json:"type"`
Icon int `json:"iconCategory"`
Description string `json:"description"`
} `json:"incidents"`
}
type RouteSummary struct {
TravelTimeInSeconds int `json:"travelTimeInSeconds"`
TravelTimeInMinutes int `json:"travelTimeInMinutes"` // Calculé
LengthInMeters int `json:"lengthInMeters"`
LengthInKm float64 `json:"lengthInKm"` // Calculé
TrafficDelayInSeconds int `json:"trafficDelayInSeconds,omitempty"`
DepartureTime string `json:"departureTime,omitempty"`
ArrivalTime string `json:"arrivalTime,omitempty"`
}
type RouteResponse struct {
Routes []struct {
Summary RouteSummary `json:"summary"`
Legs []struct {
Summary RouteSummary `json:"summary"`
} `json:"legs,omitempty"`
} `json:"routes"`
}
// IncidentsWithRoute combine incidents et informations de route
type IncidentsWithRoute struct {
Incidents []Incident `json:"incidents"`
Route RouteSummary `json:"route"`
}
+25
View File
@@ -0,0 +1,25 @@
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"`
Amende *float64 `json:"amende,omitempty"`
}

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