76 Commits
Author SHA1 Message Date
Xor290 bf718c5b13 chore: update ci 2026-08-23 15:56:27 +02:00
Xor290 6f150710d1 chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h49m20s
Frontend Client - EAS Build / build (push) Successful in 1h43m40s
2026-08-22 15:43:24 +02:00
Xor290 8d34f531a5 chore: build
Frontend Client - EAS Build / build (push) Successful in 1h49m52s
2026-08-21 22:55:33 +02:00
Xor290 5d2f9aa05d chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h59m40s
Frontend Client - EAS Build / build (push) Canceled after 1h1m2s
2026-08-21 19:49:35 +02:00
Xor290 22c7a7b8d8 chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h56m20s
Frontend Client - EAS Build / build (push) Canceled after 1m17s
2026-08-21 17:45:32 +02:00
Xor290 632c851f9c chore: update ci 2026-08-21 17:44:15 +02:00
Xor290 b0fbd5e5fc chore: build
Frontend Client - EAS Build / build (push) Failing after 7m26s
2026-08-21 13:59:24 +02:00
Xor290 62ad698ea6 chore: update
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Admin - EAS Build / build (push) Canceled after 0s
2026-08-21 13:48:45 +02:00
Xor290 c99d913d94 chore: update ci 2026-08-21 13:44:56 +02:00
Xor290 46652bb925 chore: build
Backend - Build & Lint / build (push) Failing after 30m2s
Frontend Client - EAS Build / build (push) Canceled after 7m55s
Frontend Web - Build & Lint / build (push) Failing after 10m18s
2026-08-21 13:08:56 +02:00
Xor290 b9073954f1 chore: build
Backend - Build & Lint / build (push) Failing after 28m56s
2026-08-19 12:26:50 +02:00
Xor290 7b11e138d2 chore build
Backend - Build & Lint / build (push) Failing after 29m23s
2026-08-18 23:06:13 +02:00
Xor290 e82ad3ae4d chore: build
Backend - Build & Lint / build (push) Canceled after 29m7s
2026-08-18 20:02:59 +02:00
Xor290 ef7166c2dd chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h58m5s
2026-08-16 17:00:03 +02:00
Xor290 4916e74d1c chore: build
Frontend Client - EAS Build / build (push) Successful in 1h52m37s
2026-08-16 14:30:20 +02:00
Xor290 3023227a29 chore: build
Frontend Client - EAS Build / build (push) Canceled after 1m4s
2026-08-16 14:28:58 +02:00
Xor290 3149989286 chore: update 2026-08-16 14:27:29 +02:00
Xor290 b8fddc51c2 chore: build
Backend - Build & Lint / build (push) Failing after 31m13s
Frontend Web - Build & Lint / build (push) Failing after 9m49s
2026-08-16 13:40:55 +02:00
Xor290 32a60b4476 fix: sql request
Backend - Build & Lint / build (push) Failing after 30m53s
2026-08-10 11:27:11 +02:00
Xor290 c6830873ba chore: build
Backend - Build & Lint / build (push) Failing after 32m27s
2026-08-08 00:27:56 +02:00
Xor290 5c84dfed66 chore: build
Frontend Client - EAS Build / build (push) Successful in 2h14m57s
2026-08-07 19:59:31 +02:00
Xor290 7618954a86 chore: build
Frontend Admin - EAS Build / build (push) Successful in 2h21m3s
2026-08-07 19:58:24 +02:00
Xor290 7313063cd9 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h12m39s
Frontend Client - EAS Build / build (push) Failing after 2h6m35s
2026-08-07 13:24:33 +02:00
Xor290 190a845383 chore: update CI 2026-08-07 13:24:01 +02:00
Xor290 3a87eff949 chore: build
Backend - Build & Lint / build (push) Failing after 32m42s
2026-08-06 13:29:52 +02:00
Xor290 22a8d5026c chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s
2026-08-06 12:06:05 +02:00
Nuxgrid c034088bee chore: build 2026-07-11 19:58:32 +02:00
Nuxgrid 536bce4625 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-07-11 19:58:15 +02:00
Nuxgrid 5ac824be81 chore: update
Backend - Build & Lint / build (push) Failing after 23m33s
Frontend Web - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 2h4m41s
2026-07-11 19:05:21 +02:00
Xor290 28b9491338 chore: build 2026-06-30 19:19:50 +02:00
Xor290 1ebd19b797 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h1m12s
2026-06-25 19:22:51 +02:00
Xor290 5d92316093 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-24 15:40:01 +02:00
Xor290 26cd84a334 chore: build
Backend - Build & Lint / build (push) Failing after 17m30s
2026-06-23 22:31:34 +02:00
Xor290 e8c79e40ec chore: build mobile
Frontend Client - EAS Build / build (push) Failing after 1h1m44s
2026-06-20 22:47:17 +02:00
Xor290 cefebf0b43 chore: build
Frontend Web - Build & Lint / build (push) Failing after 13m30s
2026-06-20 22:25:31 +02:00
Xor290 0caa96e633 chore: build
Backend - Build & Lint / build (push) Failing after 17m59s
2026-06-20 21:21:04 +02:00
Xor290 47cf7cfd76 chore: build
Backend - Build & Lint / build (push) Failing after 15m18s
2026-06-20 15:33:46 +02:00
Xor290 95784de527 chore: update ci 2026-06-20 15:32:09 +02:00
Xor290 0067ec2234 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-20 15:27:05 +02:00
Xor290 c5f3a293f9 chore: build 2026-06-20 15:26:41 +02:00
Xor290 acda6f72fb chore: build
Backend - Build & Lint / build (push) Failing after 1m22s
2026-06-20 15:20:21 +02:00
Xor290 0cfd6f392e chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 14:29:40 +02:00
Xor290 189a414321 chore: build 2026-06-20 14:29:13 +02:00
Xor290 28149689fb chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 13:37:34 +02:00
Xor290 37f8165920 chore: build
Frontend Client - EAS Build / build (push) Failing after 48m0s
2026-06-20 12:39:23 +02:00
Xor290 c0a3b16f12 chore: build
Frontend Client - EAS Build / build (push) Failing after 48m18s
2026-06-20 08:40:47 +02:00
Xor290 13bba226d6 chore: update ci 2026-06-20 08:40:21 +02:00
Xor290 9dba774344 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 08:27:05 +02:00
Xor290 b0071780fc chore: update ci 2026-06-20 08:26:33 +02:00
Xor290 f791801956 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 08:16:25 +02:00
Xor290 4f6d26c14c chore: update ci 2026-06-20 08:15:40 +02:00
Xor290 ed68d237c3 chore: build
Frontend Client - EAS Build / build (push) Failing after 39m41s
2026-06-19 21:24:22 +02:00
Xor290 184f224125 chore: build
Frontend Web - Build & Lint / build (push) Failing after 2m5s
2026-06-19 21:09:08 +02:00
Xor290 47ff590aa0 chore: update ci 2026-06-19 21:07:54 +02:00
Xor290 0687171cf3 chore: build
Frontend Web - Build & Lint / build (push) Failing after 15m43s
2026-06-19 20:47:54 +02:00
Xor290 b0aba29150 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-19 20:35:12 +02:00
Xor290 80bc862f6a chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-19 20:26:05 +02:00
Xor290 46202df927 chore: update ci 2026-06-19 20:25:45 +02:00
Xor290 c9909022fe chore: update CI 2026-06-18 12:52:03 +02:00
Xor290 78193604da chore: build 2026-06-16 08:45:54 +02:00
Xor290 7a9530069b chore: update docker 2026-06-16 08:45:16 +02:00
Xor290 74bb299099 chore: build 2026-06-16 08:40:51 +02:00
Xor290 153780073f chore: fix 2026-06-15 22:36:12 +02:00
Xor290 a6bc62b6e6 chore: fix ci 2026-06-15 22:35:51 +02:00
Xor290 48e6cbcb4c chore: build 2026-06-15 19:13:51 +02:00
Xor290 c7d43e7641 chore: build 2026-06-14 18:21:13 +02:00
Xor290 3a70a6f4c7 chore: update 2026-06-14 18:19:09 +02:00
Xor290 c471a2a734 chore: fix 2026-06-14 18:18:37 +02:00
Xor290 6d4e0862ff chore: build 2026-06-14 18:09:44 +02:00
Xor290 3a0f725159 chore: build 2026-06-14 17:50:35 +02:00
Xor290 3c92a0371f chore: update CI 2026-06-14 17:49:58 +02:00
Xor290 ad8ecbe452 chore: update 2026-06-14 17:39:43 +02:00
Xor290 0835c06d8b chore: delete monitoring 2026-06-10 13:59:46 +02:00
Xor290 27558d7651 chore: update ci 2026-06-10 13:58:38 +02:00
Xor290 ee0abcd223 fix: telegram link 2026-05-12 21:39:59 +02:00
Xor290 d13a980447 chore: build 2026-05-09 16:38:22 +02:00
252 changed files with 28969 additions and 8572 deletions
+25 -55
View File
@@ -2,41 +2,17 @@ name: Backend - Build & Lint
on: on:
push: push:
branches: [main] branches: [main, pre-prod]
paths: paths:
- "backend/**/**" - "backend/**/**"
pull_request: pull_request:
branches: [main] branches: [main, pre-prod]
paths: paths:
- "backend/**/**" - "backend/**/**"
jobs: 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: build:
name: Build
needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -50,6 +26,21 @@ jobs:
working-directory: backend/gestion working-directory: backend/gestion
run: go mod download run: go mod download
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
continue-on-error: true
with:
version: latest
working-directory: backend/gestion
args: --timeout=5m
- name: Install & run gosec
working-directory: backend/gestion
continue-on-error: true
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
- name: Build - name: Build
working-directory: backend/gestion working-directory: backend/gestion
run: go build -v ./... run: go build -v ./...
@@ -61,54 +52,33 @@ jobs:
path: backend/gestion/gestion path: backend/gestion/gestion
retention-days: 7 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 - name: Login to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: github.event_name == 'push'
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build & push backend (runtime) - name: Build & push backend (runtime)
if: github.event_name == 'push'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/backend/Dockerfile file: docker-prod/backend/Dockerfile
target: runtime target: runtime
push: true push: true
tags: xor1234/backend-mln:latest tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
- name: Build & push WAF - name: Build & push WAF
if: github.event_name == 'push'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/backend/Dockerfile file: docker-prod/backend/Dockerfile
target: waf target: waf
push: true push: true
tags: xor1234/backend-mln:waf tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
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
+118 -40
View File
@@ -11,9 +11,8 @@ on:
- "frontend-admin/**" - "frontend-admin/**"
jobs: jobs:
typecheck: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,6 +23,29 @@ jobs:
cache: npm cache: npm
cache-dependency-path: frontend-admin/package-lock.json cache-dependency-path: frontend-admin/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install EAS CLI & tooling
run: |
n=0
until [ $n -ge 3 ]; do
npm install -g eas-cli && break
n=$((n + 1))
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
npm uninstall -g eas-cli >/dev/null 2>&1 || true
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
sleep 5
done
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
pip install -r scripts/requirements.txt awscli --quiet
- name: Install dependencies - name: Install dependencies
working-directory: frontend-admin working-directory: frontend-admin
run: npm ci run: npm ci
@@ -32,29 +54,28 @@ jobs:
working-directory: frontend-admin working-directory: frontend-admin
run: npx tsc --noEmit run: npx tsc --noEmit
build-apk: - name: Determine config
needs: typecheck id: config
runs-on: ubuntu-latest run: |
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
steps: echo "profile=production" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4 echo "channel=production-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
- name: Setup Node.js echo "ota_api_url=https://mln-uber.club" >> $GITHUB_OUTPUT
uses: actions/setup-node@v4 echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
with: echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
node-version: 20 echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
cache: npm echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
cache-dependency-path: frontend-admin/package-lock.json else
echo "profile=pre-prod" >> $GITHUB_OUTPUT
- name: Setup Expo & EAS CLI echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
uses: expo/expo-github-action@v8 echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
with: echo "ota_api_url=https://5.181.0.112.nip.io" >> $GITHUB_OUTPUT
eas-version: latest echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
token: ${{ secrets.EXPO_TOKEN }} echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PREPROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
- name: Install dependencies echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
working-directory: frontend-admin fi
run: npm ci
- name: Inject EAS project ID - name: Inject EAS project ID
working-directory: frontend-admin working-directory: frontend-admin
@@ -62,21 +83,78 @@ jobs:
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
mv app.tmp.json app.json mv app.tmp.json app.json
- name: Build APK - name: Select code signing certificate
working-directory: frontend-admin # certs/certificate.pem (committé) correspond à la clé de signature
env: # du serveur OTA de production ; le serveur pre-prod signe avec une
EAS_BUILD_NO_EXPO_GO_WARNING: true # clé différente (PRIVATE_KEY_PREPROD côté ota-uber), donc les builds
run: eas build --platform android ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && '--profile production' || '--profile preview' }} --non-interactive # pre-prod doivent embarquer certs/certificate-preprod.pem à la place,
# sous peine de voir toute MAJ OTA rejetée silencieusement (signature
- name: Download APK # invalide) sur ce canal.
working-directory: frontend-admin working-directory: frontend-admin
run: | run: |
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl') if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
curl -L -o admin-panel-prod.apk "$APK_URL" cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Upload production APK artifact - name: Restore Gradle cache (RustFS)
uses: actions/upload-artifact@v4 env:
with: AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
name: admin-panel-android-prod-apk AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
path: frontend-admin/admin-panel-prod.apk S3_ENDPOINT: https://rustfs.uber-stup.club
retention-days: 14 S3_BUCKET: apk-builds
run: python scripts/eas_cache.py restore --app frontend-admin
- name: Build APK (local)
working-directory: frontend-admin
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
EAS_BUILD_NO_EXPO_GO_WARNING: true
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2"
JAVA_TOOL_OPTIONS: "-Xmx3g"
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
- name: Save Gradle cache (RustFS)
if: success() || failure()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
S3_ENDPOINT: https://rustfs.uber-stup.club
S3_BUCKET: apk-builds
run: python scripts/eas_cache.py save --app frontend-admin
- name: Rename & upload APK to RustFS
working-directory: frontend-admin
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
mv *.apk ${{ steps.config.outputs.apk_name }}
aws s3 cp ${{ steps.config.outputs.apk_name }} \
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
--endpoint-url https://rustfs.uber-stup.club \
--no-verify-ssl
- name: Publish OTA update to Xavia
if: github.event_name == 'push'
working-directory: frontend-admin
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
NODE_OPTIONS: "--max-old-space-size=2048"
run: |
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
-F "file=@bundle.zip" \
-F "runtimeVersion=$RUNTIME_VERSION" \
-F "channel=${{ steps.config.outputs.channel }}" \
-F "commitHash=${{ github.sha }}" \
-F "commitMessage=${{ steps.config.outputs.message }}" \
--fail
+118 -44
View File
@@ -11,9 +11,8 @@ on:
- "mobile/**" - "mobile/**"
jobs: jobs:
typecheck: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -24,6 +23,29 @@ jobs:
cache: npm cache: npm
cache-dependency-path: mobile/package-lock.json cache-dependency-path: mobile/package-lock.json
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install EAS CLI & tooling
run: |
n=0
until [ $n -ge 3 ]; do
npm install -g eas-cli && break
n=$((n + 1))
echo "npm install -g eas-cli failed (attempt $n/3), cleaning up and retrying in 5s..."
npm uninstall -g eas-cli >/dev/null 2>&1 || true
rm -rf "$(npm root -g)/eas-cli" "$(npm root -g)"/.eas-cli-* 2>/dev/null || true
sleep 5
done
command -v eas >/dev/null || { echo "::error::eas-cli installation failed after 3 attempts"; exit 1; }
pip install -r scripts/requirements.txt awscli --quiet
- name: Install dependencies - name: Install dependencies
working-directory: mobile working-directory: mobile
run: npm ci run: npm ci
@@ -32,29 +54,28 @@ jobs:
working-directory: mobile working-directory: mobile
run: npx tsc --noEmit run: npx tsc --noEmit
build-apk: - name: Determine config
needs: typecheck id: config
runs-on: ubuntu-latest run: |
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
steps: echo "profile=production" >> $GITHUB_OUTPUT
- uses: actions/checkout@v4 echo "channel=production-client" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
- name: Setup Node.js echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
uses: actions/setup-node@v4 echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
with: echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
node-version: 20 echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
cache: npm echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
cache-dependency-path: mobile/package-lock.json else
echo "profile=pre-prod" >> $GITHUB_OUTPUT
- name: Setup Expo & EAS CLI echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
uses: expo/expo-github-action@v8 echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
with: echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
eas-version: latest echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
token: ${{ secrets.EXPO_TOKEN }} echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PREPROD }}" >> $GITHUB_OUTPUT
echo "apk_name=mobile-pre-prod-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
- name: Install dependencies echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
working-directory: mobile fi
run: npm ci
- name: Inject EAS project ID - name: Inject EAS project ID
working-directory: mobile working-directory: mobile
@@ -62,25 +83,78 @@ jobs:
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
mv app.tmp.json app.json mv app.tmp.json app.json
- name: Debug app.json - name: Select code signing certificate
working-directory: mobile # certs/certificate.pem (committé) correspond à la clé de signature
run: cat app.json # du serveur OTA mobile de production ; le serveur pre-prod signe
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
- name: Build APK # donc les builds pre-prod doivent embarquer
working-directory: mobile # certs/certificate-preprod.pem à la place, sous peine de voir toute
env: # MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
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 working-directory: mobile
run: | run: |
APK_URL=$(eas build:list --platform android --status finished --limit 1 --json --non-interactive | jq -r '.[0].artifacts.buildUrl') if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
curl -L -o client-panel-prod.apk "$APK_URL" cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Upload production APK artifact - name: Restore Gradle cache (RustFS)
uses: actions/upload-artifact@v4 env:
with: AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
name: client-panel-android-prod-apk AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
path: mobile/client-panel-prod.apk S3_ENDPOINT: https://rustfs.uber-stup.club
retention-days: 14 S3_BUCKET: apk-builds
run: python scripts/eas_cache.py restore --app mobile
- name: Build APK (local)
working-directory: mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
EAS_BUILD_NO_EXPO_GO_WARNING: true
NODE_OPTIONS: "--max-old-space-size=2048"
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.workers.max=2 -Dorg.gradle.internal.repository.max.retries=10 -Dorg.gradle.internal.repository.initial.backoff.ms=1000"
JAVA_TOOL_OPTIONS: "-Xmx3g"
run: eas build --platform android --profile ${{ steps.config.outputs.profile }} --local --non-interactive
- name: Save Gradle cache (RustFS)
if: success() || failure()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
S3_ENDPOINT: https://rustfs.uber-stup.club
S3_BUCKET: apk-builds
run: python scripts/eas_cache.py save --app mobile
- name: Rename & upload APK to RustFS
working-directory: mobile
env:
AWS_ACCESS_KEY_ID: ${{ secrets.RUSTFS_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
AWS_DEFAULT_REGION: us-east-1
run: |
mv *.apk ${{ steps.config.outputs.apk_name }}
aws s3 cp ${{ steps.config.outputs.apk_name }} \
s3://apk-builds/${{ steps.config.outputs.profile }}/${{ steps.config.outputs.apk_name }} \
--endpoint-url https://rustfs.uber-stup.club \
--no-verify-ssl
- name: Publish OTA update to Xavia
if: github.event_name == 'push'
working-directory: mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EXPO_PUBLIC_API_URL: ${{ steps.config.outputs.ota_api_url }}
EXPO_PUBLIC_UPDATE_URL: ${{ steps.config.outputs.xavia_url }}
NODE_OPTIONS: "--max-old-space-size=2048"
run: |
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
npx expo export --platform android --output-dir dist
cd dist && zip -r ../bundle.zip . && cd ..
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
-F "file=@bundle.zip" \
-F "runtimeVersion=$RUNTIME_VERSION" \
-F "channel=${{ steps.config.outputs.channel }}" \
-F "commitHash=${{ github.sha }}" \
-F "commitMessage=${{ steps.config.outputs.message }}" \
--fail
+15 -62
View File
@@ -5,45 +5,16 @@ on:
branches: [main, pre-prod] branches: [main, pre-prod]
paths: paths:
- "frontend-prep/**" - "frontend-prep/**"
- "docker/frontend/**" - "docker-pre-prod/frontend/**"
pull_request: pull_request:
branches: [main, pre-prod] branches: [main, pre-prod]
paths: paths:
- "frontend-prep/**" - "frontend-prep/**"
- "docker/frontend/**" - "docker-pre-prod/frontend/**"
jobs: 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: build:
name: Build
needs: lint-typecheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -58,54 +29,36 @@ jobs:
working-directory: frontend-prep working-directory: frontend-prep
run: npm ci run: npm ci
- name: Typecheck & lint
working-directory: frontend-prep
run: |
npx tsc -b --noEmit
npm run lint
- name: Build - name: Build
working-directory: frontend-prep working-directory: frontend-prep
env: env:
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }} VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
run: npm run build 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 - name: Login to Docker Hub
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: docker/setup-buildx-action@v3
- name: Build & push frontend - name: Build & push frontend
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/frontend/Dockerfile file: docker-prod/frontend/Dockerfile
push: true push: true
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }} tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
build-args: | build-args: |
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }} 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
+1 -1
View File
@@ -240,7 +240,7 @@ graph TD
P3["GET /api/v1/products/category/:category"] P3["GET /api/v1/products/category/:category"]
P4["GET /api/v1/categories"] P4["GET /api/v1/categories"]
P5["GET /api/v1/app-settings"] P5["GET /api/v1/app-settings"]
P6["POST /api/v1/webhooks/nowpayments"] P6["POST /api/v1/webhook/nowpayments"]
P7["POST /webhook/telegram"] P7["POST /webhook/telegram"]
end end
+21 -5
View File
@@ -3,19 +3,35 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"gestion/utils"
"strings"
) )
func (d *Database) CheckAddress(addressByUser *models.Command) error { func (d *Database) CheckAddress(addressByUser *models.Command) error {
var correction models.Address var correction models.Address
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction) result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
if result.Error != nil { if result.Error == nil {
if isNotFound(result.Error) { addressByUser.DeliveryAddress = correction.CorrectAddress
return nil return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
} }
if !isNotFound(result.Error) {
return fmt.Errorf("checkAddress: %w", result.Error) return fmt.Errorf("checkAddress: %w", result.Error)
} }
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress) // Pas de correspondance exacte — fallback sur une comparaison normalisée
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
corrections, err := d.AllAddress()
if err != nil {
return nil
}
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
for _, c := range corrections {
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
addressByUser.DeliveryAddress = c.CorrectAddress
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
}
}
return nil
} }
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error { func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
+3 -3
View File
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) { func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Find(&alerts).Error; err != nil { if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) { func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil { if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) { func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil { if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
+155 -281
View File
@@ -3,137 +3,29 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"log"
"time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// AddProductInBasket ajoute un produit au panier de l'utilisateur // GetActiveProductPrice retourne le prix catalogue actif pour un produit et
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) { // une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
var productResult struct { // AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
ID int `gorm:"column:id"` // "half_price_product" (50% de ce prix).
} func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
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 { var result struct {
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT price FROM product_prices SELECT price FROM product_prices
WHERE product_id = ? AND quantity = ROUND(?::NUMERIC, 3) WHERE product_id = ? AND quantity <= ? AND active_price = true
LIMIT 1`, productID, quantity).Scan(&result).Error ORDER BY quantity DESC LIMIT 1`,
if err == nil && result.Price > 0 { productID, quantity).Scan(&result).Error
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 { if err != nil || result.Price == 0 {
return 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f", productID, quantity) return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
} }
return result.Price, nil 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) { func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct { var result struct {
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
@@ -153,37 +45,11 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
return result.Price, nil 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 // GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) { func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
var baskets []models.Panier var baskets []models.Panier
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at, SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
p.name as product_name, p.category, p.description p.name as product_name, p.category, p.description
FROM baskets b FROM baskets b
INNER JOIN products p ON b.product_id = p.id INNER JOIN products p ON b.product_id = p.id
@@ -195,40 +61,126 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil return baskets, nil
} }
// DecrementProductStockByID décrémente le stock d'un produit par son ID // AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error { // au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
result := d.GDB.Exec(` // ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
UPDATE products SET stock = stock - ? // Supprime les anciens items récompense avant d'insérer les nouveaux.
WHERE id = ? AND stock >= ?`, quantity, productID, quantity) // Pas de vérification de stock — les récompenses sont gérées par l'admin.
if result.Error != nil { func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", result.Error) var baskets []models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var err error
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
return err
})
if err != nil {
return nil, err
} }
if result.RowsAffected == 0 { return baskets, nil
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
}
return nil
} }
// DeleteProductFromBasket supprime un produit spécifique du panier et restitue le stock. // addRewardsToBasketTx contient la logique de remplacement des articles
func (d *Database) DeleteProductFromBasket(basketID int) error { // récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
return d.GDB.Transaction(func(tx *gorm.DB) error { // soit dans la même transaction qu'une autre opération (voir
var item struct { // ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
ProductID int `gorm:"column:product_id"` // jamais consommée sans que son produit soit effectivement livré.
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
var baskets []models.Panier
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
return nil, err
}
baskets = append(baskets, basket)
}
return baskets, nil
}
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
var counts struct {
Total int `gorm:"column:total"`
Normal int `gorm:"column:normal"`
}
err := d.GDB.Raw(`
SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE is_reward = false) as normal
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
if err != nil {
return false, err
}
return counts.Total > 0 && counts.Normal == 0, nil
}
// AddToBasket vérifie le stock disponible et ajoute l'article au panier.
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
var basket models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
}
if currentStock < quantity {
return fmt.Errorf("stock insuffisant")
}
var priceResult struct {
Price float64 `gorm:"column:price"`
}
if err := tx.Raw(`
SELECT price FROM product_prices
WHERE product_id = ? AND quantity <= ? AND active_price = true
ORDER BY quantity DESC LIMIT 1`,
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
} }
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ?`, basketID).Scan(&item).Error; err != nil { // Chercher uniquement un item normal (non-récompense) pour ce produit
return fmt.Errorf("produit non trouvé dans le panier") tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
} username, productID).Scan(&existing)
if item.ProductID == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, if existing.ID != 0 {
item.Quantity, item.ProductID).Error; err != nil { return tx.Raw(`
return fmt.Errorf("erreur restitution stock: %w", err) UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
existing.Quantity+quantity, existing.Price+priceResult.Price,
existing.ID).Scan(&basket).Error
} }
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
username, productID, quantity, priceResult.Price).Scan(&basket).Error
})
if err != nil {
return nil, err
}
return &basket, nil
}
result := tx.Exec(`DELETE FROM baskets WHERE id = ?`, basketID) // DeleteProductFromBasket supprime un produit spécifique du panier.
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
func (d *Database) DeleteProductFromBasket(basketID int) error {
result := d.GDB.Exec(`DELETE FROM baskets WHERE id = ?`, basketID)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error) return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
} }
@@ -236,122 +188,14 @@ func (d *Database) DeleteProductFromBasket(basketID int) error {
return fmt.Errorf("produit non trouvé dans le panier") return fmt.Errorf("produit non trouvé dans le panier")
} }
return nil return nil
})
} }
// ClearBasket vide complètement le panier d'un utilisateur et restitue les stocks. // ClearBasket vide complètement le panier d'un utilisateur.
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
func (d *Database) ClearBasket(username string) error { 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 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) { func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
@@ -364,9 +208,39 @@ func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
return username, nil return username, nil
} }
// GetUnavailableBasketItems retourne les noms des produits du panier dont tous les prix ont été désactivés.
func (d *Database) GetUnavailableBasketItems(username string) ([]string, error) {
var names []string
err := d.GDB.Raw(`
SELECT DISTINCT p.name
FROM baskets b
INNER JOIN products p ON b.product_id = p.id
WHERE b.username = ?
AND NOT EXISTS (
SELECT 1 FROM product_prices pp
WHERE pp.product_id = b.product_id
AND pp.quantity <= b.quantity
AND pp.active_price = true
)`, username).Scan(&names).Error
if err != nil {
return nil, fmt.Errorf("erreur vérification disponibilité: %w", err)
}
return names, nil
}
// GetReservedQuantityInBaskets retourne la somme des quantités d'un produit dans tous les paniers actifs.
func (d *Database) GetReservedQuantityInBaskets(productID int) (float64, error) {
var total float64
err := d.GDB.Raw(`SELECT COALESCE(SUM(quantity), 0) FROM baskets WHERE product_id = ?`, productID).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("erreur lecture réservations panier: %w", err)
}
return total, nil
}
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) { func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
var items []map[string]any 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 = ?`, if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price, is_reward FROM baskets WHERE username = ?`,
username).Scan(&items).Error; err != nil { username).Scan(&items).Error; err != nil {
return nil, err return nil, err
} }
+117 -23
View File
@@ -1,8 +1,3 @@
// ============================================
// db/cancel_commands_db.go
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
// ============================================
package db package db
import ( import (
@@ -83,13 +78,17 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
if err := tx.Exec(` if err := tx.Exec(`
UPDATE products p UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM command_items ci FROM (
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { SELECT product_id, SUM(quantite) AS total_qty
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err) FROM command_items
} else { WHERE command_id = ?
log.Printf("✅ [CancelAtomic] Stock remboursé") GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
} }
log.Printf("✅ [CancelAtomic] Stock remboursé")
if err := tx.Exec(` if err := tx.Exec(`
UPDATE clients UPDATE clients
@@ -151,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
return penalty, nil return penalty, nil
} }
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool { func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result() etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
if err != nil { if err != nil || len(etaData) == 0 {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID) log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false return false
} }
var etaMinutes int var etaMinutes int
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes) if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
if err != nil || etaMinutes <= 0 { log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
return false return false
} }
@@ -179,8 +176,6 @@ func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
} }
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error { 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 { return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct { var cmdResult struct {
Status string `gorm:"column:status"` Status string `gorm:"column:status"`
@@ -199,16 +194,28 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username) log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ Ne restitue le stock QUE si pas déjà fait
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
if !stockAlreadyRestored {
if err := tx.Exec(` if err := tx.Exec(`
UPDATE products p UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM command_items ci FROM (
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err) log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else { } else {
log.Printf("✅ [DeleteAtomic] Stock remboursé") log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
}
} else {
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
} }
// ✅ Log suppression
tx.Exec(` tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at) INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
@@ -316,3 +323,90 @@ func (d *Database) AddClientPenalty(username string, points int) error {
return nil return nil
} }
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
// remboursement de stock et le changement de statut se font dans la même
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
// Corrige un double remboursement possible sur double-tap/appel concurrent —
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
// par le handler) lisait le statut puis restaurait le stock hors transaction,
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
// "pas encore annulée" et remboursaient chacune le stock.
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var prevStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
return err
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
if !slices.Contains(noRestoreStatuses, prevStatus) {
if err := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
}
}
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
return nil
})
}
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
return e
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
if prevStatus == "cancelled" {
alreadyCancelled = true
return nil
}
result := tx.Exec(`
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, prevStatus)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée par une autre requête")
}
if e := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
return fmt.Errorf("erreur remboursement stock: %w", e)
}
return nil
})
return
}
+17 -2
View File
@@ -13,6 +13,7 @@ type Category struct {
Name string `json:"name" gorm:"column:name"` Name string `json:"name" gorm:"column:name"`
Color string `json:"color" gorm:"column:color"` Color string `json:"color" gorm:"column:color"`
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"` IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
Position int `json:"position" gorm:"column:position"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
} }
@@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error {
func (d *Database) GetAllCategories() ([]Category, error) { func (d *Database) GetAllCategories() ([]Category, error) {
var categories []Category var categories []Category
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil { if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
return nil, err return nil, err
} }
if categories == nil { if categories == nil {
@@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
if color == "" { if color == "" {
color = "#7c3aed" color = "#7c3aed"
} }
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon} var maxPos int
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
if err := d.GDB.Create(&c).Error; err != nil { if err := d.GDB.Create(&c).Error; err != nil {
return nil, err return nil, err
} }
@@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error {
return nil return nil
} }
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
func (d *Database) ReorderCategories(ids []int) error {
tx := d.GDB.Begin()
for i, id := range ids {
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
func (d *Database) CategoryExists(name string) (bool, error) { func (d *Database) CategoryExists(name string) (bool, error) {
var count int64 var count int64
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
+200 -104
View File
@@ -190,44 +190,6 @@ func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword
return nil 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) { func (d *Database) GetClientAmende(username string) (float64, error) {
var result struct { var result struct {
Amende float64 `gorm:"column:amende"` Amende float64 `gorm:"column:amende"`
@@ -242,37 +204,6 @@ func (d *Database) GetClientAmende(username string) (float64, error) {
return result.Amende, nil 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 // IncrementClientCommandCount incrémente le compteur de commandes du client
func (d *Database) IncrementClientCommandCount(username string) error { func (d *Database) IncrementClientCommandCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1")) result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
@@ -361,6 +292,28 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error
} }
// GetClientByUsername récupère un client par son username // GetClientByUsername récupère un client par son username
// GetClientsByUsernames charge plusieurs clients en une seule requête.
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
result := make(map[string]*models.Client, len(usernames))
if len(usernames) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
}
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
}
return result, nil
}
func (d *Database) GetClientByUsername(username string) (*models.Client, error) { func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
var row struct { var row struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
@@ -374,10 +327,12 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
MustChangePassword bool `gorm:"column:must_change_password"` MustChangePassword bool `gorm:"column:must_change_password"`
PointsExtraJSON []byte `gorm:"column:points_extra"` PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, SELECT id, username, password, nom, prenom, telephone, command, amende,
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
two_fa_enabled
FROM clients WHERE username = ?`, username).Scan(&row).Error FROM clients WHERE username = ?`, username).Scan(&row).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err) return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
@@ -397,6 +352,7 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
Amende: row.Amende, Amende: row.Amende,
MustChangePassword: row.MustChangePassword, MustChangePassword: row.MustChangePassword,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
TwoFAEnabled: row.TwoFAEnabled,
} }
client.PointsExtra = map[string]int{} client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 { if len(row.PointsExtraJSON) > 0 {
@@ -406,7 +362,11 @@ func (d *Database) GetClientByUsername(username string) (*models.Client, error)
return client, nil return client, nil
} }
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) { 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]any, error) {
amende, err := d.GetClientAmende(username) amende, err := d.GetClientAmende(username)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -421,13 +381,13 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
cancellationHistory, err := d.GetClientCancellationHistory(username) cancellationHistory, err := d.GetClientCancellationHistory(username)
if err != nil { if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err) log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
cancellationHistory = map[string]interface{}{ cancellationHistory = map[string]any{
"cancellations_count": cancellationsCount, "cancellations_count": cancellationsCount,
"next_penalty": 20, "next_penalty": 20,
} }
} }
info := map[string]interface{}{ info := map[string]any{
"username": username, "username": username,
"total_penalty": amende, "total_penalty": amende,
"cancellations_count": cancellationsCount, "cancellations_count": cancellationsCount,
@@ -438,21 +398,6 @@ func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface
return info, nil 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. // ResetClientPoint réinitialise les points d'un client.
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement // extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra // extraPoolKey == "" (poolIdx=-1) → reset total points_extra
@@ -488,17 +433,13 @@ func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey s
return nil return nil
} }
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error { func (d *Database) ResetClientPenalties(username string, _ bool) error {
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount) log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
var query string result := d.GDB.Exec(
if resetCancellationsCount { `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
query = `UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?` username,
} else { )
query = `UPDATE clients SET amende = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`
}
result := d.GDB.Exec(query, username)
if result.Error != nil { if result.Error != nil {
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error) log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset pénalités: %w", result.Error) return fmt.Errorf("erreur reset pénalités: %w", result.Error)
@@ -513,12 +454,12 @@ func (d *Database) ResetClientPenalties(username string, resetCancellationsCount
return nil return nil
} }
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) { func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
var rows []struct { var rows []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
Amende float64 `gorm:"column:amende"` Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"` CancellationsCount int `gorm:"column:cancellations_count"`
UpdatedAt interface{} `gorm:"column:updated_at"` UpdatedAt any `gorm:"column:updated_at"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
@@ -530,9 +471,9 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error
return nil, fmt.Errorf("erreur récupération clients: %w", err) return nil, fmt.Errorf("erreur récupération clients: %w", err)
} }
clients := make([]map[string]interface{}, 0, len(rows)) clients := make([]map[string]any, 0, len(rows))
for _, row := range rows { for _, row := range rows {
clients = append(clients, map[string]interface{}{ clients = append(clients, map[string]any{
"username": row.Username, "username": row.Username,
"total_penalty": row.Amende, "total_penalty": row.Amende,
"cancellations_count": row.CancellationsCount, "cancellations_count": row.CancellationsCount,
@@ -545,7 +486,7 @@ func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error
return clients, nil return clients, nil
} }
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) { func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
var result struct { var result struct {
ClientsWithPenalties int `gorm:"column:clients_with_penalties"` ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
TotalPenalties float64 `gorm:"column:total_penalties"` TotalPenalties float64 `gorm:"column:total_penalties"`
@@ -567,7 +508,7 @@ func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
return nil, fmt.Errorf("erreur récupération stats: %w", err) return nil, fmt.Errorf("erreur récupération stats: %w", err)
} }
stats := map[string]interface{}{ stats := map[string]any{
"clients_with_penalties": result.ClientsWithPenalties, "clients_with_penalties": result.ClientsWithPenalties,
"total_penalties": result.TotalPenalties, "total_penalties": result.TotalPenalties,
"average_penalty": result.AvgPenalty, "average_penalty": result.AvgPenalty,
@@ -690,6 +631,51 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int,
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username) log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
// ✅ ÉTAPE 3: Déduire les points des récompenses reçues dans cette commande
var rewardItems []struct {
RewardPoolKey string `gorm:"column:reward_pool_key"`
}
if err := tx.Raw(`
SELECT reward_pool_key FROM command_items
WHERE command_id = ? AND is_reward = true AND reward_pool_key != ''
`, commandID).Scan(&rewardItems).Error; err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur query reward items: %v", err)
}
for _, ri := range rewardItems {
if settings.PointsReward == nil || settings.PointsReward.Threshold <= 0 {
break
}
threshold := settings.PointsReward.Threshold
poolKey := ri.RewardPoolKey
// Déduire threshold points de points_extra[poolKey] (plancher à 0)
if err := tx.Exec(`
UPDATE clients
SET points_extra = jsonb_set(
COALESCE(points_extra, '{}'::jsonb),
ARRAY[?],
to_jsonb(GREATEST(0, COALESCE((points_extra->>?)::int, 0) - ?))
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, poolKey, poolKey, threshold, username).Error; err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur déduction points reward pool=%s: %v", poolKey, err)
} else {
log.Printf("🎁 [CalcPointsTx] Récompense reçue: -%d pts pool=%s → %s", threshold, poolKey, username)
}
// Décrémenter points_redeemed[poolKey] (plancher à 0)
if err := tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(GREATEST(0, COALESCE((points_redeemed->>?)::int, 0) - 1))
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, poolKey, poolKey, username).Error; err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur décrément redeemed pool=%s: %v", poolKey, err)
}
}
return totalPoints, pointCategory, nil return totalPoints, pointCategory, nil
} }
@@ -727,3 +713,113 @@ func (d *Database) CanUserAccessCommand(
return exists, err return exists, err
} }
// GetClientPointsAndRewards retourne les points cumulés et les récompenses réclamées pour un client.
func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[string]int, pointsRedeemed map[string]int, err error) {
var row struct {
PointsExtraJSON []byte `gorm:"column:points_extra"`
PointsRedeemedJSON []byte `gorm:"column:points_redeemed"`
}
if err = d.GDB.Raw(`
SELECT COALESCE(points_extra, '{}'::jsonb) as points_extra,
COALESCE(points_redeemed, '{}'::jsonb) as points_redeemed
FROM clients WHERE username = ?`, username).Scan(&row).Error; err != nil {
return nil, nil, fmt.Errorf("erreur lecture points client: %w", err)
}
pointsExtra = map[string]int{}
pointsRedeemed = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &pointsExtra)
}
if len(row.PointsRedeemedJSON) > 0 {
json.Unmarshal(row.PointsRedeemedJSON, &pointsRedeemed)
}
return pointsExtra, pointsRedeemed, nil
}
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
// pool donné, dans la transaction fournie — factorisée pour être appelée
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
// ne soit jamais consommée sans que son produit soit effectivement livré.
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return 0, fmt.Errorf("erreur lecture: %w", err)
}
earned := row.Points / threshold
available := earned - row.Redeemed
if available <= 0 {
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
}
if err := tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error; err != nil {
return 0, err
}
return earned - (row.Redeemed + 1), nil
}
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
return err
})
if err != nil {
return 0, err
}
return remainingAvailable, nil
}
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
if err != nil {
return err
}
if len(items) > 0 {
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, nil, err
}
return remainingAvailable, added, nil
}
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
if poolKey != "" {
return d.GDB.Exec(`
UPDATE clients SET points_redeemed = points_redeemed - ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, poolKey, username).Error
}
return d.GDB.Exec(`
UPDATE clients SET points_redeemed = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error
}
+141 -81
View File
@@ -3,10 +3,40 @@ package db
import ( import (
"fmt" "fmt"
"log" "log"
"slices"
"strings" "strings"
"time" "time"
"gorm.io/gorm"
) )
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
type commandItemFull struct {
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID int `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
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"`
}
func (commandItemFull) TableName() string { return "command_items" }
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
if len(items) == 0 {
return nil
}
return d.GDB.Create(&items).Error
}
// ============================================ // ============================================
// VALIDATION HELPERS // VALIDATION HELPERS
// ============================================ // ============================================
@@ -79,13 +109,11 @@ func validateItemStatus(status string) error {
status = strings.ToLower(strings.TrimSpace(status)) status = strings.ToLower(strings.TrimSpace(status))
for _, valid := range validStatuses { if !slices.Contains(validStatuses, status) {
if status == valid { return fmt.Errorf("statut invalide: %s", status)
return nil
}
} }
return fmt.Errorf("statut invalide: %s", status) return nil
} }
// ============================================ // ============================================
@@ -98,6 +126,8 @@ func (d *Database) InsertCommandItemWithClientInfo(
productID int, productID int,
quantite float64, quantite float64,
prix float64, prix float64,
isReward bool,
rewardPoolKey string,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
) error { ) error {
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit) log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
@@ -115,9 +145,12 @@ func (d *Database) InsertCommandItemWithClientInfo(
return err return err
} }
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
if !isReward {
if err := validatePrix(prix); err != nil { if err := validatePrix(prix); err != nil {
return err return err
} }
}
if err := validateUsername(clientUsername); err != nil { if err := validateUsername(clientUsername); err != nil {
return err return err
@@ -162,10 +195,12 @@ func (d *Database) InsertCommandItemWithClientInfo(
err := d.GDB.Exec(` err := d.GDB.Exec(`
INSERT INTO command_items ( INSERT INTO command_items (
command_id, produit, product_id, quantite, prix, command_id, produit, product_id, quantite, prix,
is_reward, reward_pool_key,
client_username, client_nom, client_prenom, client_telephone, delivery_address, client_username, client_nom, client_prenom, client_telephone, delivery_address,
status, created_at, updated_at status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
commandID, produit, productID, quantite, prix, commandID, produit, productID, quantite, prix,
isReward, rewardPoolKey,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
).Error ).Error
if err != nil { if err != nil {
@@ -181,7 +216,7 @@ func (d *Database) InsertCommandItemWithClientInfo(
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL // GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
// ============================================ // ============================================
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) { func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID) log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
// ✅ VALIDATION // ✅ VALIDATION
@@ -197,6 +232,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
ProductID *int64 `gorm:"column:product_id"` ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"` Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"` Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"` ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"` ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"` ClientPrenom string `gorm:"column:client_prenom"`
@@ -212,6 +249,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
LivreurAssign *string `gorm:"column:livreur_assign"` LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"` Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"` ClientOrderNumber int `gorm:"column:client_order_number"`
} }
@@ -223,6 +261,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
ci.product_id, ci.product_id,
ci.quantite, ci.quantite,
ci.prix, ci.prix,
ci.is_reward,
ci.reward_pool_key,
ci.client_username, ci.client_username,
ci.client_nom, ci.client_nom,
ci.client_prenom, ci.client_prenom,
@@ -237,7 +277,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
c.referral_used, c.referral_used,
c.livreur_assign, c.livreur_assign,
c.created_at as command_created_at, c.created_at as command_created_at,
p.category, COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number c.client_order_id as client_order_number
FROM command_items ci FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id LEFT JOIN commandes c ON ci.command_id = c.id
@@ -249,25 +290,27 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
return nil, fmt.Errorf("erreur récupération items: %w", err) return nil, fmt.Errorf("erreur récupération items: %w", err)
} }
items := make([]map[string]interface{}, 0, len(rows)) items := make([]map[string]any, 0, len(rows))
for _, row := range rows { for _, row := range rows {
productIDValue := 0 productIDValue := 0
if row.ProductID != nil { if row.ProductID != nil {
productIDValue = int(*row.ProductID) productIDValue = int(*row.ProductID)
} }
var commandCreatedAt interface{} var commandCreatedAt any
if row.CommandCreatedAt != nil { if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt commandCreatedAt = *row.CommandCreatedAt
} }
item := map[string]interface{}{ item := map[string]any{
"id": row.ID, "id": row.ID,
"command_id": row.CommandID, "command_id": row.CommandID,
"produit": row.Produit, "produit": row.Produit,
"product_id": productIDValue, "product_id": productIDValue,
"quantite": row.Quantite, "quantite": row.Quantite,
"prix": row.Prix, "prix": row.Prix,
"is_reward": row.IsReward,
"reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername, "client_username": row.ClientUsername,
"client_nom": row.ClientNom, "client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom, "client_prenom": row.ClientPrenom,
@@ -284,6 +327,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
"livreur_assign": ptrStr(row.LivreurAssign), "livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt, "command_created_at": commandCreatedAt,
"category": row.Category, "category": row.Category,
"unit": row.Unit,
"client_order_number": row.ClientOrderNumber, "client_order_number": row.ClientOrderNumber,
} }
items = append(items, item) items = append(items, item)
@@ -293,18 +337,12 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
return items, nil return items, nil
} }
// ptrStr retourne la valeur d'un *string ou "" si nil // GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
func ptrStr(s *string) string { // Retourne map[commandID][]items, même structure que GetCommandItems.
if s == nil { func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
return "" result := make(map[int][]map[string]any, len(commandIDs))
} if len(commandIDs) == 0 {
return *s return result, nil
}
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 { var rows []struct {
@@ -314,6 +352,8 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte
ProductID *int64 `gorm:"column:product_id"` ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"` Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"` Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"` ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"` ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"` ClientPrenom string `gorm:"column:client_prenom"`
@@ -325,80 +365,78 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte
CommandStatus *string `gorm:"column:command_status"` CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"` CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"` TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"` LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT SELECT
ci.id, ci.id, ci.command_id, ci.produit, ci.product_id,
ci.command_id, ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
ci.produit, ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
ci.product_id, ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
ci.quantite, c.status as command_status, c.adresse as command_address,
ci.prix, c.total_prix, c.referral_used, c.livreur_assign,
ci.client_username, c.created_at as command_created_at,
ci.client_nom, COALESCE(p.category, '') as category,
ci.client_prenom, COALESCE(p.unit, '') as unit,
ci.client_telephone, c.client_order_id as client_order_number
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 FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id LEFT JOIN commandes c ON ci.command_id = c.id
WHERE ci.client_username = ? LEFT JOIN products p ON ci.product_id = p.id
ORDER BY ci.command_id DESC, ci.id ASC`, username).Scan(&rows).Error WHERE ci.command_id IN ?
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
if err != nil { if err != nil {
log.Printf("❌ Erreur query: %v", err) return nil, fmt.Errorf("erreur récupération items batch: %w", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
} }
items := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows { for _, row := range rows {
productIDValue := 0 productIDValue := 0
if row.ProductID != nil { if row.ProductID != nil {
productIDValue = int(*row.ProductID) productIDValue = int(*row.ProductID)
} }
var commandCreatedAt any
var commandCreatedAt interface{}
if row.CommandCreatedAt != nil { if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt commandCreatedAt = *row.CommandCreatedAt
} }
item := map[string]any{
item := map[string]interface{}{ "id": row.ID, "command_id": row.CommandID,
"id": row.ID, "produit": row.Produit, "product_id": productIDValue,
"command_id": row.CommandID, "quantite": row.Quantite, "prix": row.Prix,
"produit": row.Produit, "is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
"product_id": productIDValue, "client_username": row.ClientUsername, "client_nom": row.ClientNom,
"quantite": row.Quantite, "client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
"prix": row.Prix, "delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
"client_username": row.ClientUsername, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
"client_nom": row.ClientNom, "command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
"client_prenom": row.ClientPrenom, "total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
"client_telephone": row.ClientTelephone, "livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
"delivery_address": ptrStr(row.DeliveryAddress), "category": row.Category, "unit": row.Unit,
"status": ptrStr(row.Status), "client_order_number": row.ClientOrderNumber,
"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) result[row.CommandID] = append(result[row.CommandID], item)
} }
return result, nil
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
return items, nil
} }
// ptrStr retourne la valeur d'un *string ou "" si nil
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
// que la suppression et le remboursement, pour éviter une course avec une
// annulation concurrente de la commande entière (qui rembourserait déjà cet
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
func (d *Database) DeleteCommandItem(commandID, itemID int) error { func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
@@ -409,33 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
return err return err
} }
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
return fmt.Errorf("erreur vérification commande: %w", err)
}
if cmdStatus == "" {
return fmt.Errorf("commande %d non trouvée", commandID)
}
var result struct { var result struct {
Prix float64 `gorm:"column:prix"` Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"` Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
} }
if err := d.GDB.Raw(`SELECT prix, quantite FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil { if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err) return fmt.Errorf("erreur vérification item: %w", err)
} }
if result.Prix == 0 && result.Quantite == 0 { if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID) return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
} }
// Supprimer l'item if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
if err := d.GDB.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err) log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err) return fmt.Errorf("erreur suppression item: %w", err)
} }
// Recalculer le total de la commande if err := tx.Exec(
if err := d.GDB.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`, `UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID, result.Prix*result.Quantite, commandID,
).Error; err != nil { ).Error; err != nil {
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err) log.Printf(" [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
} }
return nil return nil
})
} }
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error { func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
-83
View File
@@ -7,7 +7,6 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models"
"log" "log"
"slices" "slices"
) )
@@ -44,88 +43,6 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
return commands, nil 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 // GetPendingCommandsStats récupère des statistiques sur les commandes en attente
func (d *Database) GetPendingCommandsStats() (map[string]any, error) { func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
var result struct { var result struct {
+201 -148
View File
@@ -1,6 +1,8 @@
package db package db
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"gestion/models" "gestion/models"
"log" "log"
@@ -11,6 +13,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
var errAlreadyApproved = errors.New("already_approved")
func sanitizeString(s string) string { func sanitizeString(s string) string {
sanitized := strings.Map(func(r rune) rune { sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 { if r < 32 || r == 127 {
@@ -48,18 +53,8 @@ type basketItem struct {
ProductID int `gorm:"column:product_id"` ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
} IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
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 // validateCommandStatus vérifie si le statut est valide
@@ -82,72 +77,6 @@ func validateCommandStatus(status string) error {
return nil 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) { func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
if err := validateUsername(username); err != nil { if err := validateUsername(username); err != nil {
return nil, err return nil, err
@@ -171,24 +100,31 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
clientTelephone = sanitizeString(client.Telephone) clientTelephone = sanitizeString(client.Telephone)
} }
basketItems, totalPrix, err := d.fetchBasketItems(username) var (
if err != nil { command *models.Command
log.Printf("❌ Erreur query basket: %v", err) totalPrix float64
return nil, err )
err = d.GDB.Transaction(func(tx *gorm.DB) error {
// Verrou sur le panier : un double-submit concurrent du même client se
// bloque ici puis échoue proprement ("panier vide") une fois le premier
// passage terminé, au lieu de créer une commande fantôme.
var basketItems []basketItem
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
} }
if len(basketItems) == 0 { if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide") return fmt.Errorf("le panier est vide")
} }
for _, item := range basketItems { for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 { if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return nil, fmt.Errorf("données panier invalides") return fmt.Errorf("données panier invalides")
} }
totalPrix += item.Price
} }
if totalPrix <= 0 || totalPrix > 100000 { if totalPrix <= 0 || totalPrix > 100000 {
return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix) return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
} }
var cmdResult struct { var cmdResult struct {
@@ -197,54 +133,67 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at"`
} }
err = d.GDB.Raw(` if err := tx.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at) 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) 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`, RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
if err != nil { return fmt.Errorf("erreur création commande: %w", err)
return nil, fmt.Errorf("erreur création commande: %w", err)
} }
commandID := cmdResult.ID commandID := cmdResult.ID
productIDs2 := make([]int, 0, len(basketItems))
for _, item := range basketItems { for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID) productIDs2 = append(productIDs2, item.ProductID)
if err != nil || productName == "" { }
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
batchItems := make([]commandItemFull, 0, len(basketItems))
for _, item := range basketItems {
productName := productNames2[item.ProductID]
if productName == "" {
productName = fmt.Sprintf("Produit #%d", item.ProductID) productName = fmt.Sprintf("Produit #%d", item.ProductID)
} }
batchItems = append(batchItems, commandItemFull{
err = d.InsertCommandItemWithClientInfo( CommandID: commandID,
commandID, Produit: productName,
productName, ProductID: item.ProductID,
item.ProductID, Quantite: item.Quantity,
item.Quantity, Prix: item.Price,
item.Price, IsReward: item.IsReward,
username, RewardPoolKey: item.RewardPoolKey,
clientNom, ClientUsername: username,
clientPrenom, ClientNom: clientNom,
clientTelephone, ClientPrenom: clientPrenom,
deliveryAddress, ClientTelephone: clientTelephone,
) DeliveryAddress: deliveryAddress,
if err != nil { Status: "pending",
log.Printf("❌ Erreur INSERT command_items: %v", err) })
return nil, fmt.Errorf("erreur insertion items: %w", err) }
if err := tx.Create(&batchItems).Error; err != nil {
return fmt.Errorf("erreur insertion items: %w", err)
} }
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici. // Les articles récompense (payés en points) restent des produits physiques
// réellement distribués : le stock doit être décrémenté comme pour un
// article payant.
for _, item := range basketItems {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return err
} }
if err := d.GDB.Delete(&models.Panier{}, "username = ?", username).Error; err != nil { command = &models.Command{
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, ID: commandID,
ClientOrderID: cmdResult.ClientOrderID, ClientOrderID: cmdResult.ClientOrderID,
Username: username, Username: username,
@@ -254,6 +203,18 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
CreatedAt: cmdResult.CreatedAt, CreatedAt: cmdResult.CreatedAt,
UpdatedAt: cmdResult.UpdatedAt, UpdatedAt: cmdResult.UpdatedAt,
} }
return nil
})
if err != nil {
log.Printf("❌ Erreur création commande: %v", err)
return nil, err
}
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
d.AddCommandLog(command.ID, "created",
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
username)
return command, nil return command, nil
} }
@@ -349,14 +310,6 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]any, er
return commands, nil 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 { func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
} }
@@ -376,13 +329,17 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
ReferralUsed float64 `gorm:"column:referral_used"` ReferralUsed float64 `gorm:"column:referral_used"`
ClientOrderNumber int `gorm:"column:client_order_number"` ClientOrderNumber int `gorm:"column:client_order_number"`
CancelReason string `gorm:"column:cancel_reason"` CancelReason string `gorm:"column:cancel_reason"`
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
} }
if err := d.GDB.Table("commandes c"). if err := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign, 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.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
c.referral_used, c.client_order_id AS client_order_number, c.referral_used, c.client_order_id AS client_order_number,
COALESCE(c.cancel_reason, '') AS cancel_reason`). COALESCE(c.cancel_reason, '') AS cancel_reason,
COALESCE(c.dest_latitude, 0) AS dest_latitude,
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
Where("c.id = ?", id). Where("c.id = ?", id).
First(&row).Error; err != nil { First(&row).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err) return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
@@ -403,6 +360,8 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
"referral_used": row.ReferralUsed, "referral_used": row.ReferralUsed,
"client_order_number": row.ClientOrderNumber, "client_order_number": row.ClientOrderNumber,
"cancel_reason": row.CancelReason, "cancel_reason": row.CancelReason,
"dest_latitude": row.DestLatitude,
"dest_longitude": row.DestLongitude,
} }
if row.LivreurAssign != nil { if row.LivreurAssign != nil {
@@ -420,6 +379,54 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
return command, nil return command, nil
} }
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
}
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
return coords.Lat, coords.Lon, nil
}
}
var result struct {
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
if err := d.GDB.Table("commandes").
Select("dest_latitude, dest_longitude").
Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0",
livreurUsername, "livre", "delivered", "approved").
Order("updated_at DESC").
Limit(1).
Scan(&result).Error; err != nil {
return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err)
}
if result.DestLatitude == 0 || result.DestLongitude == 0 {
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
}
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
}
return result.DestLatitude, result.DestLongitude, nil
}
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global. // GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
// Retourne commandID en fallback si introuvable. // Retourne commandID en fallback si introuvable.
func (d *Database) GetClientOrderID(commandID int) int { func (d *Database) GetClientOrderID(commandID int) int {
@@ -432,20 +439,6 @@ func (d *Database) GetClientOrderID(commandID int) int {
return result.ClientOrderID 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 { func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
if len(deliveryAddress) > 500 { if len(deliveryAddress) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)") return fmt.Errorf("adresse trop longue (max 500 caractères)")
@@ -469,6 +462,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil return nil
} }
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
// de statut se fait dans la clause WHERE, atomiquement : impossible de
// modifier la commande d'un autre client ou une commande déjà en route.
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
if err := validateAddress(deliveryAddress); err != nil {
return err
}
result := d.GDB.Exec(`
UPDATE commandes
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
deliveryAddress, commandID, clientUsername)
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 introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
}
d.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
clientUsername)
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
return nil
}
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client // ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error { func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil { if err := validateAddress(proposedAddress); err != nil {
@@ -632,7 +655,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s", log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
cmd.Status, cmd.Username, cmd.LivreurAssign) cmd.Status, cmd.Username, cmd.LivreurAssign)
validStatuses := []string{"assigned", "en_route", "pending", "livre"} validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, cmd.Status) { if !slices.Contains(validStatuses, cmd.Status) {
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status) log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status) return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
@@ -757,6 +780,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
return fmt.Errorf("cette commande ne vous appartient pas") return fmt.Errorf("cette commande ne vous appartient pas")
} }
if cmd.Status == "approved" {
log.Printf("️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
return errAlreadyApproved
}
if cmd.Status != "livre" { if cmd.Status != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status) log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
@@ -806,6 +834,9 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
}) })
if err != nil { if err != nil {
if errors.Is(err, errAlreadyApproved) {
return 0, "", nil
}
return 0, "", err return 0, "", err
} }
@@ -861,13 +892,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
return fmt.Errorf("commande non trouvée") return fmt.Errorf("commande non trouvée")
} }
if cmd.Status != "livre" { // Historiquement restreint à "livre" seul (cf. commentaire de
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) // TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
// incident réel où une vérification GPS en amont (coordonnées de
// destination périmées après un changement d'adresse, cf.
// updateCommandDestinationCoords) a bloqué la transition du livreur
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
// cabine ne puissent confirmer la réception. On accepte désormais tout
// statut non terminal ("arrived" inclus), à l'image de
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
// pour que le staff garde toujours un moyen de débloquer une commande
// légitime indépendamment d'un blocage en amont côté livreur.
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
} }
result := tx.Exec(` result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre'`, commandID) WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error) return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
} }
@@ -925,3 +968,13 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
return totalPoints, pointCategory, clientUsernameOut, nil return totalPoints, pointCategory, clientUsernameOut, nil
} }
func (d *Database) SetCommandCancelReason(commandID int, reason string) error {
if len(reason) > 500 {
reason = reason[:500]
}
return d.GDB.Exec(
`UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
reason, commandID,
).Error
}
+28
View File
@@ -0,0 +1,28 @@
package db
import (
"gestion/models"
)
func (d *Database) AddContact(contact *models.Contact) error {
err := d.GDB.Create(contact).Error
return err
}
func (d *Database) GetContact(id uint) (*models.Contact, error) {
var contact models.Contact
if err := d.GDB.First(&contact, id).Error; err != nil {
return nil, err
}
return &contact, nil
}
func (d *Database) UpdateContact(contact *models.Contact) error {
err := d.GDB.Save(contact).Error
return err
}
func (d *Database) DeleteContact(id uint) error {
err := d.GDB.Delete(&models.Contact{}, id).Error
return err
}
+4 -17
View File
@@ -95,7 +95,6 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}) })
} }
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) { 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 query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
FROM commandes FROM commandes
@@ -106,6 +105,10 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
if status != "" { if status != "" {
query += " AND status = ?" query += " AND status = ?"
args = append(args, status) args = append(args, status)
} else {
// Ne retourner que les commandes actives — exclure l'historique terminé
// pour éviter le N+1 sur 66+ commandes qui fait timeout le mobile
query += " AND status IN ('assigned', 'en_route', 'arrived', 'livre')"
} }
query += " ORDER BY created_at DESC" query += " ORDER BY created_at DESC"
@@ -117,22 +120,6 @@ func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status stri
return commands, nil 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 { func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error { return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct { var cmdResult struct {
-76
View File
@@ -221,79 +221,3 @@ func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) e
} }
return nil 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
}
-4
View File
@@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string {
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon) 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 { func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
return MapLinks{ return MapLinks{
WazeApp: wazeAppLink(lat, lon), 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 { func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
return wazeAppLink(toLat, toLon) 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) { func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername) _, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil { if err != nil {
-113
View File
@@ -8,8 +8,6 @@ package db
import ( import (
"fmt" "fmt"
"log" "log"
"maps"
"strconv"
) )
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur // GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
@@ -37,114 +35,3 @@ func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string
} }
return commands, nil 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
}
+110 -1
View File
@@ -53,7 +53,7 @@ func InitDB() *Database {
// Configuration du pool de connexions // Configuration du pool de connexions
db.SetMaxOpenConns(50) db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10) db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxLifetime(30 * time.Minute)
// Tester la connexion // Tester la connexion
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
@@ -66,6 +66,8 @@ func InitDB() *Database {
gormDB, err := gorm.Open(postgres.New(postgres.Config{ gormDB, err := gorm.Open(postgres.New(postgres.Config{
Conn: db, Conn: db,
}), &gorm.Config{ }), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: true,
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
}) })
if err != nil { if err != nil {
@@ -120,6 +122,24 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err) log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
} }
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
}
// Migration: baskets.reward_pool_key — pool de points utilisé pour la récompense
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration baskets.reward_pool_key: %v", err)
}
// Migration: command_items.is_reward + reward_pool_key
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration command_items.is_reward: %v", err)
}
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS reward_pool_key VARCHAR(100) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires // Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(` if _, err = database.Exec(`
DO $$ DO $$
@@ -147,6 +167,23 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err) log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
} }
// Migration: position d'affichage des catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration categories.position: %v", err)
}
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
if _, err = database.Exec(`
UPDATE categories c
SET position = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
FROM categories
) sub
WHERE c.id = sub.id AND c.position = 0
`); err != nil {
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
}
// Migration: table paramètres globaux de l'application // Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings ( if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY, key VARCHAR(100) PRIMARY KEY,
@@ -236,6 +273,44 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err) log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
} }
// Migration: récompenses réclamées par pool (nb de fois que la récompense a été obtenue)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_redeemed JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
log.Fatalf("❌ Erreur migration clients.points_redeemed: %v", err)
}
// Migration: flag "à venir" sur les produits
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration products.coming_soon: %v", err)
}
// Migration: prix actif/inactif sur les prix de produits
if _, err = database.Exec(`ALTER TABLE product_prices ADD COLUMN IF NOT EXISTS active_price BOOLEAN NOT NULL DEFAULT TRUE`); err != nil {
log.Fatalf("❌ Erreur migration product_prices.active_price: %v", err)
}
// Migration: table contacts (SAV Telegram)
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS contacts (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
)`); err != nil {
log.Fatalf("❌ Erreur migration contacts: %v", err)
}
// Migration: clé RustFS pour les médias (stockage objet)
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration media.key: %v", err)
}
// Migration: colonne parrain sur les clients (système de parrainage)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
}
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés // Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically() go database.cleanExpiredTokensPeriodically()
@@ -280,6 +355,7 @@ func (db *Database) createTables() error {
cancellations_count INTEGER DEFAULT 0 NOT NULL, cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL, last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0, referral_balance NUMERIC(10,2) DEFAULT 0.0,
parrain VARCHAR(255) DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`, );`,
@@ -341,6 +417,7 @@ func (db *Database) createTables() error {
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL, url TEXT NOT NULL,
type VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL,
key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`, );`,
@@ -464,6 +541,38 @@ func (db *Database) createTables() error {
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(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_status ON delivery_issues(status);`,
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`, `CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
// ============================
// TABLE contacts
// ============================
`CREATE TABLE IF NOT EXISTS contacts (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);`,
// ============================
// TABLE livreur_ratings
// ============================
`CREATE TABLE IF NOT EXISTS livreur_ratings (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE,
livreur_username VARCHAR(255) NOT NULL,
client_username VARCHAR(255) NOT NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
// ============================
// TABLE login_history (livreur uniquement)
// ============================
`CREATE TABLE IF NOT EXISTS login_history (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
} }
for _, query := range queries { for _, query := range queries {
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"time"
)
type LivreurRating struct {
ID int `json:"id"`
OrderID int `json:"order_id"`
LivreurUsername string `json:"livreur_username"`
ClientUsername string `json:"client_username"`
Rating int `json:"rating"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error {
return d.GDB.Exec(`
INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at)
VALUES (?, ?, ?, ?, ?, NOW())
`, orderID, livreurUsername, clientUsername, rating, comment).Error
}
func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
var r LivreurRating
err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error
if err != nil {
return nil, err
}
if r.ID == 0 {
return nil, nil
}
return &r, nil
}
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
var ratings []LivreurRating
if err := d.GDB.Raw(`
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
`, livreurUsername).Scan(&ratings).Error; err != nil {
return nil, 0, err
}
var avg float64
if len(ratings) > 0 {
d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg)
}
return ratings, avg, nil
}
// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée
func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) {
var row struct {
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err = d.GDB.Raw(`
SELECT username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1
`, orderID).Scan(&row).Error
return row.Username, row.LivreurAssign, err
}
+34
View File
@@ -0,0 +1,34 @@
package db
import (
"time"
)
type LoginHistoryEntry struct {
ID int `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant).
func (d *Database) RecordLivreurLogin(username string) error {
return d.GDB.Exec(`
INSERT INTO login_history (username, created_at)
VALUES (?, NOW())
`, username).Error
}
// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné,
// triées du plus récent au plus ancien (max 50 entrées).
func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) {
var entries []LoginHistoryEntry
err := d.GDB.Raw(`
SELECT id, username, created_at FROM login_history
WHERE username = ?
AND EXTRACT(YEAR FROM created_at) = ?
AND EXTRACT(MONTH FROM created_at) = ?
ORDER BY created_at DESC
LIMIT 50
`, username, year, month).Scan(&entries).Error
return entries, err
}
+28 -9
View File
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") { if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
return fmt.Errorf("path traversal détecté dans l'URL") return fmt.Errorf("path traversal détecté dans l'URL")
} }
if !strings.HasPrefix(url, "/uploads/") { if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
return fmt.Errorf("URL doit commencer par /uploads/") return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
} }
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"} dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
for _, char := range dangerousChars { for _, char := range dangerousChars {
@@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error {
mediaType := m.GetType() mediaType := m.GetType()
mediaURL := m.GetURL() mediaURL := m.GetURL()
mediaKey := ""
if mediaPtr, isPtr := media.(*models.Media); isPtr {
mediaKey = mediaPtr.Key
}
if err := validateProductID(productID); err != nil { if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err) log.Printf("❌ [CreateMedia] %v", err)
return err return err
@@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error {
return err return err
} }
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil { if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
log.Printf("❌ [InsertMedia] %v", err) log.Printf("❌ [InsertMedia] %v", err)
return err return err
} }
return nil return nil
} }
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error { func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
var exists bool var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil { 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) log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
@@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
INSERT INTO media (product_id, url, type, created_at) INSERT INTO media (product_id, url, type, key, created_at)
VALUES (?, ?, ?, ?) RETURNING id`, VALUES (?, ?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, time.Now(), productID, mediaURL, mediaType, key, time.Now(),
).Scan(&result).Error ).Scan(&result).Error
if err != nil { if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err) log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
@@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
var media models.Media var media models.Media
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at SELECT id, product_id, url, type, key, created_at
FROM media WHERE id = ?`, mediaID).Scan(&media).Error FROM media WHERE id = ?`, mediaID).Scan(&media).Error
if err != nil { if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err) log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
@@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
return &media, nil return &media, nil
} }
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
result := make(map[int][]models.Media, len(productIDs))
if len(productIDs) == 0 {
return result
}
var mediaList []models.Media
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
for _, m := range mediaList {
result[m.ProductID] = append(result[m.ProductID], m)
}
return result
}
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) { func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID) log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
@@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
var mediaList []models.Media var mediaList []models.Media
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at SELECT id, product_id, url, type, key, created_at
FROM media WHERE product_id = ? FROM media WHERE product_id = ?
ORDER BY id ASC`, productID).Scan(&mediaList).Error ORDER BY id ASC`, productID).Scan(&mediaList).Error
if err != nil { if err != nil {
+56 -25
View File
@@ -9,6 +9,18 @@ import (
"time" "time"
) )
// sendTelegramNotif envoie via le bot principal, puis lbtelegram (BOT1/BOT2) en fallback.
func sendTelegramNotif(chatID int64, text string) {
if err := services.TelegramBot.SendMessage(chatID, text); err != nil {
log.Printf("⚠️ [NOTIF] bot principal échoué: %v — fallback lbtelegram", err)
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
if err2 := services.LBTelegram.SendNotification(chatID, text); err2 != nil {
log.Printf("⚠️ [NOTIF] lbtelegram aussi échoué: %v", err2)
}
}
}
}
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error { func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username) notifKey := fmt.Sprintf("notifications:%s", username)
@@ -21,12 +33,20 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
} }
notifJSON, _ := json.Marshal(notification) notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON) pipe := Redis.Pipeline()
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok { if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message)) dedupKey := fmt.Sprintf("notif:dedup:%d:%s", commandID, notifType)
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} else {
log.Printf("⚠️ [NOTIF] Doublon détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
}
} }
} }
@@ -34,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
return nil return nil
} }
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error { func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username) notifKey := fmt.Sprintf("notifications:%s", username)
@@ -47,12 +66,20 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
} }
notifJSON, _ := json.Marshal(notification) notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON) pipe2 := Redis.Pipeline()
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe2.LPush(RedisCtx, notifKey, notifJSON)
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
pipe2.Expire(RedisCtx, notifKey, time.Hour)
pipe2.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok { if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
go services.TelegramBot.SendMessage(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message)) dedupKey := fmt.Sprintf("notif:dedup:livreur:%d:%s", commandID, notifType)
if set, _ := Redis.SetNX(RedisCtx, dedupKey, "1", 5*time.Minute).Result(); set {
go sendTelegramNotif(chatID, fmt.Sprintf("🔔 <b>Notification</b>\n\n%s", message))
} else {
log.Printf("⚠️ [NOTIF] Doublon livreur détecté (cmd=%d, type=%s) — Telegram ignoré", commandID, notifType)
}
} }
} }
@@ -60,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
return nil return nil
} }
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) { func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
var users []struct { var users []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
@@ -81,25 +107,27 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
} }
notifJSON, _ := json.Marshal(notification) notifJSON, _ := json.Marshal(notification)
count := 0 pipe := Redis.Pipeline()
for _, u := range users { for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username) notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON) pipe.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { count := len(users)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
for _, u := range users {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID capturedChatID := chatID
capturedMsg := msg go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", 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) 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) { func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
var users []struct { var users []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
@@ -120,20 +148,23 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
} }
notifJSON, _ := json.Marshal(notification) notifJSON, _ := json.Marshal(notification)
count := 0 pipe := Redis.Pipeline()
for _, u := range users { for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username) notifKey := fmt.Sprintf("notifications:%s", u.Username)
Redis.LPush(RedisCtx, notifKey, notifJSON) pipe.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsConfigured() && services.TelegramBot.IsNotificationsEnabled() { count := len(users)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
for _, u := range users {
if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok { if chatID, ok, err := d.GetUserTelegramChatID(u.Username); err == nil && ok {
capturedChatID := chatID capturedChatID := chatID
capturedBody := body go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", 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) log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
} }
+36 -2
View File
@@ -1,8 +1,11 @@
package db package db
import ( import (
"database/sql"
"fmt" "fmt"
"gestion/models" "gestion/models"
"gorm.io/gorm"
) )
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error { func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
@@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
return nil return nil
} }
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
result := tx.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")
}
if creditAmount > 0 {
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("parrain non trouvé")
}
}
return nil
})
}
func (d *Database) GetClientParrain(clientUsername string) (string, error) { func (d *Database) GetClientParrain(clientUsername string) (string, error) {
var parrain string var parrain sql.NullString
err := d.GDB.Table("clients"). err := d.GDB.Table("clients").
Select("parrain"). Select("parrain").
Where("username = ?", clientUsername). Where("username = ?", clientUsername).
Scan(&parrain).Error Scan(&parrain).Error
return parrain, err if err != nil {
return "", err
}
return parrain.String, nil
} }
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) { func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
+13 -3
View File
@@ -3,7 +3,6 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"log"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -67,6 +66,17 @@ func (d *Database) ActivateCryptoCommand(commandID int) error {
func (d *Database) CancelCryptoCommand(commandID int) error { func (d *Database) CancelCryptoCommand(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error { return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
return err
}
if cmdStatus == "" {
return fmt.Errorf("commande non trouvée")
}
if cmdStatus != "pending_payment" {
return fmt.Errorf("commande non annulable (statut: %s)", cmdStatus)
}
type item struct { type item struct {
ProductID int ProductID int
Quantite float64 Quantite float64
@@ -77,9 +87,9 @@ func (d *Database) CancelCryptoCommand(commandID int) error {
} }
for _, it := range items { for _, it := range items {
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil { 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 fmt.Errorf("erreur restauration stock produit %d: %w", it.ProductID, err)
} }
} }
return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error return tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = NOW() WHERE id = ?`, commandID).Error
}) })
} }
+141 -39
View File
@@ -5,6 +5,8 @@ import (
"gestion/models" "gestion/models"
"log" "log"
"time" "time"
"gorm.io/gorm"
) )
// CreateProduct crée un nouveau produit avec ses prix // CreateProduct crée un nouveau produit avec ses prix
@@ -52,10 +54,15 @@ func (d *Database) CreateProduct(product any) error {
UpdatedAt time.Time `gorm:"column:updated_at"` UpdatedAt time.Time `gorm:"column:updated_at"`
} }
comingSoonVal := false
if prodModel, ok2 := product.(*models.Product); ok2 {
comingSoonVal = prodModel.ComingSoon
}
err := d.GDB.Raw(` err := d.GDB.Raw(`
INSERT INTO products (name, category, description, stock, unit, created_at, updated_at) INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`, VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
).Scan(&result).Error ).Scan(&result).Error
if err != nil { if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err) log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
@@ -68,14 +75,21 @@ func (d *Database) CreateProduct(product any) error {
p.SetCreatedAt(result.CreatedAt) p.SetCreatedAt(result.CreatedAt)
p.SetUpdatedAt(result.UpdatedAt) p.SetUpdatedAt(result.UpdatedAt)
for i, price := range p.GetPrices() { if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`, priceRows := make([]models.ProductPrice, len(rawPrices))
result.ID, price.Quantity, price.Price).Error for i, price := range rawPrices {
if err != nil { priceRows[i] = models.ProductPrice{
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err) ProductID: result.ID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
return fmt.Errorf("erreur insertion prix: %v", 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] %d prix insérés", len(priceRows))
} }
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID) log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
@@ -88,7 +102,7 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
var p models.Product var p models.Product
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products FROM products
WHERE id = ?`, id).Scan(&p).Error WHERE id = ?`, id).Scan(&p).Error
if err != nil { if err != nil {
@@ -110,12 +124,54 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
return p, nil return p, nil
} }
// GetProductNamesByIDs retourne un map id→name pour une liste d'IDs.
func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
result := make(map[int]string, len(ids))
if len(ids) == 0 {
return result, nil
}
rows, err := d.GDB.Raw(`SELECT id, name FROM products WHERE id IN ?`, ids).Rows()
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err == nil {
result[id] = name
}
}
return result, nil
}
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
result := make(map[int]string, len(ids))
if len(ids) == 0 {
return result, nil
}
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var id int
var category string
if err := rows.Scan(&id, &category); err == nil {
result[id] = category
}
}
return result, nil
}
func (d *Database) GetAllProducts() ([]models.Product, error) { func (d *Database) GetAllProducts() ([]models.Product, error) {
log.Println("📦 [GetAllProducts] START") log.Println("📦 [GetAllProducts] START")
var products []models.Product var products []models.Product
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products FROM products
ORDER BY id ASC`).Scan(&products).Error ORDER BY id ASC`).Scan(&products).Error
if err != nil { if err != nil {
@@ -123,23 +179,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
return nil, err return nil, err
} }
for i := range products { productIDs := make([]int, len(products))
prices, err := d.GetProductPrices(products[i].ID) for i, p := range products {
if err != nil { productIDs[i] = p.ID
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)
} }
allPrices := d.GetProductPricesBatch(productIDs)
media, err := d.GetMediaByProductID(products[i].ID) allMedia := d.GetMediaBatch(productIDs)
if err != nil { for i := range products {
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err) if prices, ok := allPrices[products[i].ID]; ok {
products[i].Media = []models.Media{} products[i].Prices = prices
} else { } else {
products[i].Prices = []models.ProductPrice{}
}
if media, ok := allMedia[products[i].ID]; ok {
products[i].Media = media products[i].Media = media
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID) } else {
products[i].Media = []models.Media{}
} }
} }
@@ -153,7 +208,7 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
var products []models.Product var products []models.Product
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, created_at, updated_at SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products FROM products
WHERE category = ? WHERE category = ?
ORDER BY created_at DESC`, category).Scan(&products).Error ORDER BY created_at DESC`, category).Scan(&products).Error
@@ -162,14 +217,16 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
} }
catProductIDs := make([]int, len(products))
for i, p := range products {
catProductIDs[i] = p.ID
}
catPrices := d.GetProductPricesBatch(catProductIDs)
for i := range products { for i := range products {
prices, err := d.GetProductPrices(products[i].ID) if prices, ok := catPrices[products[i].ID]; ok {
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 products[i].Prices = prices
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID) } else {
products[i].Prices = []models.ProductPrice{}
} }
} }
@@ -177,28 +234,73 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
return products, nil return products, nil
} }
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, stock float64, prices []models.ProductPrice) error { func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
err := d.GDB.Exec(` err := d.GDB.Exec(`
UPDATE products UPDATE products
SET name = ?, category = ?, description = ?, stock = ?, unit = ?, updated_at = ? SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
WHERE id = ?`, WHERE id = ?`,
name, category, description, stock, unit, time.Now(), productID).Error name, category, description, unit, comingSoon, time.Now(), productID).Error
if err != nil { if err != nil {
return fmt.Errorf("erreur mise à jour produit: %w", err) return fmt.Errorf("erreur mise à jour produit: %w", err)
} }
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID) d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
for _, price := range prices { if len(prices) > 0 {
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price) VALUES (?, ?, ?)`, priceRows := make([]models.ProductPrice, len(prices))
productID, price.Quantity, price.Price).Error; err != nil { for i, price := range prices {
log.Printf("❌ [UpdateProduct] Erreur prix: %v", err) priceRows[i] = models.ProductPrice{
ProductID: productID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
return fmt.Errorf("erreur insertion prix: %w", err)
} }
} }
return nil return nil
} }
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
// sans lui, une modification admin pourrait écraser silencieusement le
// décrément d'une commande passée au même instant sur le même produit.
func (d *Database) SetProductStock(productID int, stock float64) error {
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var exists int
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
return fmt.Errorf("erreur verrouillage produit: %w", err)
}
if exists == 0 {
return fmt.Errorf("produit non trouvé")
}
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
stock, time.Now(), productID).Error; err != nil {
return fmt.Errorf("erreur mise à jour stock: %w", err)
}
return nil
})
if err != nil {
return err
}
return nil
}
func (d *Database) SetProductComingSoon(productID int, comingSoon bool) error {
result := d.GDB.Exec(`UPDATE products SET coming_soon = ?, updated_at = ? WHERE id = ?`,
comingSoon, time.Now(), productID)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour coming_soon: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit non trouvé")
}
return nil
}
// DeleteProduct supprime un produit // DeleteProduct supprime un produit
func (d *Database) DeleteProduct(productID int) error { func (d *Database) DeleteProduct(productID int) error {
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID) result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
+23 -12
View File
@@ -13,19 +13,27 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
return prices, nil return prices, nil
} }
func (d *Database) CreateProductPrice(productID int, quantity float64, price float64) error { // GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
p := models.ProductPrice{ProductID: productID, Quantity: quantity, Price: price} func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
if err := d.GDB.Create(&p).Error; err != nil { result := make(map[int][]models.ProductPrice, len(productIDs))
return fmt.Errorf("erreur création prix: %w", err) if len(productIDs) == 0 {
return result
} }
return nil var prices []models.ProductPrice
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
for _, p := range prices {
result[p.ProductID] = append(result[p.ProductID], p)
}
return result
} }
func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float64) error { func (d *Database) AddActivePrice(priceID int) error {
result := d.GDB.Model(&models.ProductPrice{}).Where("id = ?", priceID). result := d.GDB.Model(&models.ProductPrice{}).
Updates(map[string]any{"quantity": quantity, "price": price}) Where("id = ?", priceID).
Update("active_price", true)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("erreur mise à jour prix: %w", result.Error) return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable") return fmt.Errorf("prix introuvable")
@@ -33,10 +41,13 @@ func (d *Database) UpdateProductPrice(priceID int, quantity float64, price float
return nil return nil
} }
func (d *Database) DeleteProductPrice(priceID int) error { func (d *Database) DeActivePrice(priceID int) error {
result := d.GDB.Delete(&models.ProductPrice{}, priceID) result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID).
Update("active_price", false)
if result.Error != nil { if result.Error != nil {
return fmt.Errorf("erreur suppression prix: %w", result.Error) return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable") return fmt.Errorf("prix introuvable")
+1 -1
View File
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
} }
// 4. Vérifier toutes les autres queues de livreurs (au cas où) // 4. Vérifier toutes les autres queues de livreurs (au cas où)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys { for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" { if len(key) > 6 && key[len(key)-6:] == ":count" {
continue continue
-14
View File
@@ -58,17 +58,3 @@ func (d *Database) ResetClientReferralBalance(username string) error {
} }
return nil 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
}
+40 -37
View File
@@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
return result.Count, nil 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é // penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
func penaltyForCount(count int, tiers []models.PenaltyTier) int { func penaltyForCount(count int, tiers []models.PenaltyTier) int {
if len(tiers) == 0 { if len(tiers) == 0 {
@@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
return sorted[len(sorted)-1].Amount return sorted[len(sorted)-1].Amount
} }
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
settings = DefaultSettings()
}
return settings.PenaltyTiers
}
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré // CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
func (d *Database) CalculateCancellationPenalty(username string) (int, error) { func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username) count, err := d.GetClientCancellationsCount(username)
@@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
return 0, err return 0, err
} }
settings, err := d.GetSettings() penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
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", log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty) username, count, penalty)
@@ -85,29 +70,47 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
return penalty, nil return penalty, nil
} }
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations // ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
func (d *Database) ApplyCancellationPenalty(username string) (int, error) { func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
penalty, err := d.CalculateCancellationPenalty(username) tiers := d.penaltyTiers("ApplyCancellationPenalty")
if err != nil {
return 0, err var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var count int
if err := tx.Raw(`
SELECT COALESCE(cancellations_count, 0) FROM clients
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
return fmt.Errorf("erreur récupération compteur: %w", err)
} }
penalty = penaltyForCount(count, tiers)
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty) log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
if err := d.IncrementClientCancellationsCount(username); err != nil { result := tx.Exec(`
return 0, err UPDATE clients
} SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
amende = amende + ?,
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty)) updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username)
if result.Error != nil { if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error) log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error) return fmt.Errorf("erreur application pénalité: %w", result.Error)
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
return 0, fmt.Errorf("client non trouvé") return fmt.Errorf("client non trouvé")
} }
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username) log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
return nil
})
if err != nil {
return 0, err
}
cacheKey := fmt.Sprintf("client:%s", username) cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey) Redis.Del(RedisCtx, cacheKey)
+74
View File
@@ -66,10 +66,24 @@ func DefaultSettings() models.AppSettings {
}, },
}, },
}, },
ShopName: "Milieu-Nantais",
ContactTelegram: "MLN44LA",
DeliveryMode: models.DeliveryModeConfig{ DeliveryMode: models.DeliveryModeConfig{
Mode: "single", Mode: "single",
CategoryRoutes: []models.CategoryRoute{}, CategoryRoutes: []models.CategoryRoute{},
}, },
AdminColorPrimary: "#7c3aed",
AdminColorSecondary: "#000000",
AdminColorSuccess: "#4ade80",
AdminColorDanger: "#ef4444",
AdminColorWarning: "#f59e0b",
ClientColorPrimary: "#7c3aed",
ClientColorSecondary: "#000000",
ClientColorSuccess: "#4ade80",
ClientColorDanger: "#ef4444",
ClientColorWarning: "#f59e0b",
ClientTitleGradientFrom: "#a78bfa",
ClientTitleGradientTo: "#22d3ee",
DeliverySchedule: DefaultDeliverySchedule(), DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []models.PostalZone{ PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}}, {Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
@@ -81,6 +95,7 @@ func DefaultSettings() models.AppSettings {
"44860", "44220", "44118", "44710", "44690", "44119", "44860", "44220", "44118", "44710", "44690", "44119",
}}, }},
}, },
Telegram2FAEnabled: false,
} }
} }
@@ -109,6 +124,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil { if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
settings.PointsPools = pools settings.PointsPools = pools
} }
case "points_reward":
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
}
case "referral_enabled": case "referral_enabled":
settings.ReferralEnabled = row.Value == "true" settings.ReferralEnabled = row.Value == "true"
case "referral_amount": case "referral_amount":
@@ -138,6 +158,8 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil { if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
settings.PostalZones = zones settings.PostalZones = zones
} }
case "contact_telegram":
settings.ContactTelegram = row.Value
case "telegram_bot_token": case "telegram_bot_token":
settings.TelegramBotToken = row.Value settings.TelegramBotToken = row.Value
case "telegram_bot_username": case "telegram_bot_username":
@@ -149,6 +171,34 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil { if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
settings.DeliveryMode = mode settings.DeliveryMode = mode
} }
case "telegram_2fa_enabled":
settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name":
settings.ShopName = row.Value
case "admin_color_primary":
settings.AdminColorPrimary = row.Value
case "admin_color_secondary":
settings.AdminColorSecondary = row.Value
case "admin_color_success":
settings.AdminColorSuccess = row.Value
case "admin_color_danger":
settings.AdminColorDanger = row.Value
case "admin_color_warning":
settings.AdminColorWarning = row.Value
case "client_color_primary":
settings.ClientColorPrimary = row.Value
case "client_color_secondary":
settings.ClientColorSecondary = row.Value
case "client_color_success":
settings.ClientColorSuccess = row.Value
case "client_color_danger":
settings.ClientColorDanger = row.Value
case "client_color_warning":
settings.ClientColorWarning = row.Value
case "client_title_gradient_from":
settings.ClientTitleGradientFrom = row.Value
case "client_title_gradient_to":
settings.ClientTitleGradientTo = row.Value
} }
} }
return settings, nil return settings, nil
@@ -180,6 +230,11 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation pools: %w", err) return fmt.Errorf("erreur sérialisation pools: %w", err)
} }
rewardJSON, err := json.Marshal(s.PointsReward)
if err != nil {
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
}
if s.NowPaymentsCurrencies == nil { if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{} s.NowPaymentsCurrencies = []string{}
} }
@@ -209,11 +264,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err) return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
} }
if s.ContactTelegram == "" {
s.ContactTelegram = "MLN44LA"
}
pairs := [][2]string{ pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)}, {"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"show_amende_score", boolStr(s.ShowAmendeScore)}, {"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)}, {"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)}, {"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
@@ -226,7 +285,22 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"telegram_bot_token", s.TelegramBotToken}, {"telegram_bot_token", s.TelegramBotToken},
{"telegram_bot_username", s.TelegramBotUsername}, {"telegram_bot_username", s.TelegramBotUsername},
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)}, {"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
{"delivery_mode", string(deliveryModeJSON)}, {"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName},
{"contact_telegram", s.ContactTelegram},
{"admin_color_primary", s.AdminColorPrimary},
{"admin_color_secondary", s.AdminColorSecondary},
{"admin_color_success", s.AdminColorSuccess},
{"admin_color_danger", s.AdminColorDanger},
{"admin_color_warning", s.AdminColorWarning},
{"client_color_primary", s.ClientColorPrimary},
{"client_color_secondary", s.ClientColorSecondary},
{"client_color_success", s.ClientColorSuccess},
{"client_color_danger", s.ClientColorDanger},
{"client_color_warning", s.ClientColorWarning},
{"client_title_gradient_from", s.ClientTitleGradientFrom},
{"client_title_gradient_to", s.ClientTitleGradientTo},
} }
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
+462
View File
@@ -0,0 +1,462 @@
package db
import (
"gestion/models"
"time"
)
// ── Reset des sections de stats ─────────────────────────────────────────────
// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section.
func (d *Database) ResetAdminStat(section string) error {
now := time.Now().UTC().Format(time.RFC3339)
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
return d.GDB.Exec(upsert, section, now).Error
}
// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente).
func (d *Database) ReadResetAt(key string) time.Time {
var row struct {
Value string
}
err := d.GDB.Table("app_settings").
Select("value").
Where("key = ?", key).
Scan(&row).Error
if err != nil {
return time.Time{}
}
if row.Value != "" {
if t, err := time.Parse(time.RFC3339, row.Value); err == nil {
return t
}
}
return time.Time{}
}
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" et
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
// que la requête appelante fait une jointure où plusieurs tables possèdent une
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
if !resetAt.IsZero() {
return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
}
return baseStatus, nil
}
// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une
// seule fois puis transmises aux différentes requêtes.
type AdminStatsFilters struct {
ResetCommandes time.Time
ResetRevenus time.Time
ResetProduits time.Time
ResetHeures time.Time
ResetJours time.Time
ResetDoses time.Time
}
// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête.
func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
keys := []string{
"stats_reset_commandes_at",
"stats_reset_revenus_at",
"stats_reset_produits_at",
"stats_reset_heures_at",
"stats_reset_jours_at",
"stats_reset_doses_at",
}
var rows []struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows)
m := make(map[string]time.Time, len(keys))
for _, r := range rows {
if t, err := time.Parse(time.RFC3339, r.Value); err == nil {
m[r.Key] = t
}
}
return AdminStatsFilters{
ResetCommandes: m["stats_reset_commandes_at"],
ResetRevenus: m["stats_reset_revenus_at"],
ResetProduits: m["stats_reset_produits_at"],
ResetHeures: m["stats_reset_heures_at"],
ResetJours: m["stats_reset_jours_at"],
ResetDoses: m["stats_reset_doses_at"],
}
}
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
WHERE ` + where + `
GROUP BY dow
ORDER BY dow
`
return d.GDB.Raw(query, args...).Scan(wdRows).Error
}
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRows).Error
}
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRevRows).Error
}
// ── Commandes par jour sur un mois calendaire complet ────────────────────────
type DailyMonthStatRow struct {
Day time.Time
Count int
Revenue float64
Quantity float64
}
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
// quantité (quantity, qui reflète le volume de commandes comme count), et
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
// section de reset que son équivalent dans le résumé global (TotalOrders /
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
// après une réinitialisation partielle.
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
end := start.AddDate(0, 1, 0)
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
query := `
SELECT
d.day,
COALESCE(d.count, 0) AS count,
COALESCE(rv.revenue, 0) AS revenue,
COALESCE(qt.quantity, 0) AS quantity
FROM (
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + whereCount + `
GROUP BY DATE(created_at)
) d
LEFT JOIN (
SELECT DATE(created_at) AS day,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + whereRevenue + `
GROUP BY DATE(created_at)
) rv ON rv.day = d.day
LEFT JOIN (
SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity
FROM commandes c
JOIN command_items ci ON ci.command_id = c.id
WHERE c.created_at >= ? AND c.created_at < ?
AND ` + whereQuantity + `
GROUP BY DATE(c.created_at)
) qt ON qt.day = d.day
ORDER BY d.day
`
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
args := []interface{}{start, end}
args = append(args, argsCount...)
args = append(args, start, end)
args = append(args, argsRevenue...)
args = append(args, start, end)
args = append(args, argsQuantity...)
return d.GDB.Raw(query, args...).Scan(rows).Error
}
// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées
// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement —
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
// "revenu" une commande encore en cours qui pourrait être annulée).
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue
FROM commandes
WHERE ` + where + `
GROUP BY hour
ORDER BY hour
`
return d.GDB.Raw(query, args...).Scan(hourRows).Error
}
// ── Top produits (quantité vendue) ───────────────────────────────────────────
// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
args = append(args, limit)
query := `
SELECT
ci.product_id,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY total_quantity DESC
LIMIT ?
`
return d.GDB.Raw(query, args...).Scan(prodRows).Error
}
// ── Répartition des doses/quantités par produit ──────────────────────────────
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
`
return d.GDB.Raw(query, args...).Scan(qtyRows).Error
}
// ── Détail du jour (catégorie → produits) ────────────────────────────────────
// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error {
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE DATE(c.created_at) = CURRENT_DATE
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query).Scan(dailyRows).Error
}
func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.created_at >= ? AND c.created_at < ?
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query, start, end).Scan(dailyRows).Error
}
// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour
// une date précise.
func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE created_at >= ? AND created_at < ? AND status != 'cancelled'
`, start, end).Scan(&count).Error
return count, err
}
// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour.
func (d *Database) DailyOrdersCount() (int64, error) {
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled'
`).Scan(&count).Error
return count, err
}
// ── Résumé global ────────────────────────────────────────────────────────────
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var total int64
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
return total, err
}
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
var total float64
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
Scan(&total).Error
return total, err
}
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var activeDays int64
query := `
SELECT COUNT(DISTINCT DATE(created_at))
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&activeDays).Error
return activeDays, err
}
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var count int64
query := `
SELECT COUNT(*) FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error {
query := `
SELECT DATE(updated_at) AS day,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(updated_at)
ORDER BY day
`
return d.GDB.Raw(query, username).Scan(statsRows).Error
}
func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error {
query := `
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 weeks'
GROUP BY week_num, year
ORDER BY year, week_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error {
query := `
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month_num, year
ORDER BY year, month_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error {
query := `
SELECT COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND DATE(updated_at) = CURRENT_DATE
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
+74 -1
View File
@@ -15,6 +15,7 @@ func (d *Database) MigrateAddTelegramColumns() {
migrations := []string{ migrations := []string{
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, `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 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 { for _, q := range migrations {
if err := d.GDB.Exec(q).Error; err != nil { if err := d.GDB.Exec(q).Error; err != nil {
@@ -42,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
key := fmt.Sprintf("telegram:link:%s", token) key := fmt.Sprintf("telegram:link:%s", token)
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil { if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
return "", fmt.Errorf("Redis SET: %w", err) return "", fmt.Errorf("redis set: %w", err)
} }
return token, nil return token, nil
} }
@@ -108,6 +109,45 @@ func (d *Database) DeleteUserTelegramChatID(username string) error {
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
} }
// GetAllLinkedTelegramAccounts retourne tous les comptes ayant un telegram_chat_id non-null.
func (d *Database) GetAllLinkedTelegramAccounts() ([]struct {
ChatID int64
Username string
Role string
}, error) {
type row struct {
ChatID int64 `gorm:"column:telegram_chat_id"`
Username string `gorm:"column:username"`
Role string `gorm:"column:role"`
}
var results []row
var clients []row
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, 'client' AS role FROM clients WHERE telegram_chat_id IS NOT NULL`).Scan(&clients).Error; err != nil {
return nil, err
}
results = append(results, clients...)
var users []row
if err := d.GDB.Raw(`SELECT telegram_chat_id, username, role FROM users WHERE telegram_chat_id IS NOT NULL`).Scan(&users).Error; err != nil {
return nil, err
}
results = append(results, users...)
out := make([]struct {
ChatID int64
Username string
Role string
}, len(results))
for i, r := range results {
out[i].ChatID = r.ChatID
out[i].Username = r.Username
out[i].Role = r.Role
}
return out, nil
}
// GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id // GetUserByTelegramChatID retrouve un utilisateur (clients + users) par chat_id
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) { func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
var clientResult struct { var clientResult struct {
@@ -127,3 +167,36 @@ func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string,
return "", "", fmt.Errorf("aucun compte lié à ce chat_id") 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
}
+2 -2
View File
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
func (d *Database) GetAllUsers() ([]*models.User, error) { func (d *Database) GetAllUsers() ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil { if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
} }
return users, nil return users, nil
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) { func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil { if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
} }
return users, nil return users, nil
-1
View File
@@ -2,7 +2,6 @@ package db
import "gorm.io/gorm" import "gorm.io/gorm"
// isNotFound retourne true si l'erreur GORM est un "record not found"
func isNotFound(err error) bool { func isNotFound(err error) bool {
return err == gorm.ErrRecordNotFound return err == gorm.ErrRecordNotFound
} }
@@ -10,7 +10,7 @@ import (
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter // FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
func (d *Database) FindLeastLoadedDeliveryman() (string, error) { func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result() keys, err := scanRedisKeys("delivery:status:*")
if err != nil || len(keys) == 0 { if err != nil || len(keys) == 0 {
return "", fmt.Errorf("aucun livreur trouvé") return "", fmt.Errorf("aucun livreur trouvé")
} }
@@ -67,57 +67,6 @@ func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
return leastLoaded, nil 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) // GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) { func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames() activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
+1 -4
View File
@@ -156,10 +156,6 @@ func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int
return services.CalculateETA(distance) 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 { func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
key := fmt.Sprintf("command:eta:%d", commandID) key := fmt.Sprintf("command:eta:%d", commandID)
@@ -169,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
eta := map[string]any{ eta := map[string]any{
"command_id": commandID, "command_id": commandID,
"total_eta_minutes": totalETA, "total_eta_minutes": totalETA,
"eta_minutes": totalETA,
"queue_position": queuePosition, "queue_position": queuePosition,
"updated_at": now.Unix(), "updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(), "arrival_time": arrivalTime.Unix(),
@@ -87,35 +87,6 @@ func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman st
return nil 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 // 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 { func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID) command, err := d.GetCommandByID(commandID)
+2 -65
View File
@@ -9,11 +9,10 @@ import (
"time" "time"
) )
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
func (d *Database) CleanupInvalidQueueCommands() (int, error) { func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...") log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result() keys, err := scanRedisKeys("queue:pending:*")
if err != nil { if err != nil {
return 0, fmt.Errorf("erreur récupération des clés: %w", err) return 0, fmt.Errorf("erreur récupération des clés: %w", err)
} }
@@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
return removedCount, nil return removedCount, nil
} }
// removeInvalidCommand supprime une commande invalide de toutes les queues
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) { func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
commandIDStr := fmt.Sprintf("%d", commandID) commandIDStr := fmt.Sprintf("%d", commandID)
@@ -94,7 +92,7 @@ func (d *Database) removeInvalidCommand(key string, commandID int, reason string
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr) Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// 3. Supprimer des queues de livreurs // 3. Supprimer des queues de livreurs
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
for _, queueKey := range livreurKeys { for _, queueKey := range livreurKeys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" { if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue continue
@@ -203,64 +201,3 @@ func (d *Database) StartQueueCleanupScheduler() {
} }
}() }()
} }
// ============================================
// 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
}
+6 -14
View File
@@ -68,8 +68,9 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
overview["general_queue"] = generalQueueSize overview["general_queue"] = generalQueueSize
deliverymanQueues := make(map[string]any) deliverymanQueues := make(map[string]any)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() keys, _ := scanRedisKeys("queue:deliveryman:*")
var totalPending int64 = generalQueueSize
for _, key := range keys { for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" { if len(key) > 6 && key[len(key)-6:] == ":count" {
continue continue
@@ -83,29 +84,19 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN, "can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN), "capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
} }
} totalPending += queueSize
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 overview["total_pending"] = totalPending
return overview, nil return overview, nil
} }
// GetQueueStats - Statistiques détaillées
func (d *Database) GetQueueStats() (map[string]any, error) { func (d *Database) GetQueueStats() (map[string]any, error) {
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result() normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result() priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
var deliverymanQueueCount int64 var deliverymanQueueCount int64
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys { for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" { if len(key) > 6 && key[len(key)-6:] == ":count" {
continue continue
@@ -117,7 +108,8 @@ func (d *Database) GetQueueStats() (map[string]any, error) {
var totalWaitTime int64 var totalWaitTime int64
var commandCount int64 var commandCount int64
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result() // Limité aux 100 premières entrées pour ne pas bloquer Redis sur une grande queue
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 99).Result()
for _, result := range normalResults { for _, result := range normalResults {
commandID := extractCommandID(result.Member) commandID := extractCommandID(result.Member)
if commandID <= 0 { if commandID <= 0 {
+19 -285
View File
@@ -86,40 +86,6 @@ func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
return true 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 // AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error { func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem) data, err := json.Marshal(queueItem)
@@ -150,111 +116,6 @@ func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.Co
return nil 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) // AddToGeneralQueue ajoute une commande à la queue générale (fallback)
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error { func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem) data, err := json.Marshal(queueItem)
@@ -281,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
return nil return nil
} }
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
func (d *Database) RemoveCommandFromQueue(commandID int) error { func (d *Database) RemoveCommandFromQueue(commandID int) error {
key := fmt.Sprintf("queue:pending:%d", commandID) key := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := strconv.Itoa(commandID) commandIDStr := strconv.Itoa(commandID)
@@ -292,7 +152,7 @@ func (d *Database) RemoveCommandFromQueue(commandID int) error {
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr) pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// Trouver et retirer de la queue du livreur // Trouver et retirer de la queue du livreur
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() keys, _ := scanRedisKeys("queue:deliveryman:*")
var affectedDeliveryman string var affectedDeliveryman string
for _, queueKey := range keys { for _, queueKey := range keys {
@@ -355,109 +215,6 @@ func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
return &queue, nil 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 { func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
key := fmt.Sprintf("delivery:status:%s", username) key := fmt.Sprintf("delivery:status:%s", username)
@@ -543,49 +300,9 @@ func (d *Database) SyncAllDeliverymanStatuses() error {
}) })
} }
// 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. // iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error { func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result() keys, err := scanRedisKeys("delivery:status:*")
if err != nil { if err != nil {
return err return err
} }
@@ -602,3 +319,20 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
} }
return nil return nil
} }
func scanRedisKeys(pattern string) ([]string, error) {
var all []string
cursor := uint64(0)
for {
batch, next, err := Redis.Scan(RedisCtx, cursor, pattern, 200).Result()
if err != nil {
return nil, err
}
all = append(all, batch...)
cursor = next
if cursor == 0 {
break
}
}
return all, nil
}
@@ -288,10 +288,6 @@ func (d *Database) RecalculateQueueETAs(deliveryman string) error {
return nil return nil
} }
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
return d.RecalculateQueueETAs(deliveryman)
}
// FindNearestCommandInQueue trouve la commande la plus proche du livreur // FindNearestCommandInQueue trouve la commande la plus proche du livreur
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) { func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman) livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
-219
View File
@@ -3,7 +3,6 @@ package db
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gestion/models"
"log" "log"
"time" "time"
) )
@@ -183,221 +182,3 @@ func (d *Database) InvalidateSession(clientID int) error {
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID) log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
return nil 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
}
+19 -1
View File
@@ -13,11 +13,30 @@ require (
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.0 github.com/redis/go-redis/v9 v9.17.0
golang.org/x/crypto v0.40.0 golang.org/x/crypto v0.40.0
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -55,7 +74,6 @@ require (
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.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 golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
) )
+36
View File
@@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 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/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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+28 -1
View File
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
var req struct { var req struct {
Message string `json:"message"` Message string `json:"message"`
} }
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
usernameStr := username.(string) usernameStr := username.(string)
@@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return return
} }
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
if err = database.DeleteAlertPolicy(alertID); err != nil { if err = database.DeleteAlertPolicy(alertID); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'alerte", err) utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
return return
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
return return
} }
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists || alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"alert": alert, "alert": alert,
+196 -250
View File
@@ -1,8 +1,11 @@
package handlers package handlers
import ( import (
"crypto/rand"
"fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
@@ -52,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
claims := models.AdminClaims{ claims := models.AdminClaims{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
Role: user.Role, // ← "admin" ou "cabine" ou "livreur" Role: user.Role,
SessionID: sessionID, SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin", // Même issuer pour tous les admins Issuer: "api-admin",
Subject: strconv.Itoa(user.ID), Subject: strconv.Itoa(user.ID),
}, },
} }
@@ -70,114 +73,13 @@ func generateAdminToken(user *models.User) (string, error) {
return tokenString, nil 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) // AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
func AdminCreateClient(c *gin.Context) { func AdminCreateClient(c *gin.Context) {
if userRole := c.GetString("role"); userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des clients"})
return
}
var req models.RegisterClientRequest var req models.RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil { 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) 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)
@@ -242,8 +144,16 @@ func AdminCreateClient(c *gin.Context) {
}) })
} }
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 // LoginClient authentifie un client
func LoginClient(c *gin.Context) { func LoginClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.LoginRequest var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
@@ -251,8 +161,6 @@ func LoginClient(c *gin.Context) {
return return
} }
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByUsername(req.Username) client, err := database.GetClientByUsername(req.Username)
if err != nil || client == nil { if err != nil || client == nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username) log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
@@ -266,6 +174,33 @@ func LoginClient(c *gin.Context) {
return 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 {
log.Printf("❌ [2FA] Erreur stockage session Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
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) token, err := generateClientToken(client)
if err != nil { if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err) log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
@@ -280,7 +215,6 @@ func LoginClient(c *gin.Context) {
return return
} }
// Créer la session Redis
sessionID := uuid.New().String() sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err) log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
@@ -303,6 +237,125 @@ func LoginClient(c *gin.Context) {
}) })
} }
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})
}
// ChangePassword permet à un client de changer son mot de passe // ChangePassword permet à un client de changer son mot de passe
func ChangePassword(c *gin.Context) { func ChangePassword(c *gin.Context) {
var req struct { var req struct {
@@ -366,60 +419,6 @@ func LogoutClient(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"}) 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 // LoginAdmin authentifie un admin/cabine/livreur
func LoginAdmin(c *gin.Context) { func LoginAdmin(c *gin.Context) {
var req models.LoginRequest var req models.LoginRequest
@@ -456,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
return return
} }
if user.Role == "livreur" {
if err := database.RecordLivreurLogin(user.Username); err != nil {
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
}
}
token, _ := generateAdminToken(user) token, _ := generateAdminToken(user)
expiresAt := time.Now().Add(adminTokenDuration) expiresAt := time.Now().Add(adminTokenDuration)
@@ -497,86 +502,6 @@ func LogoutAdmin(c *gin.Context) {
// HELPERS // 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) // GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) { func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -744,18 +669,39 @@ func CreateUser(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
return return
} }
userRole := c.GetString("role") if c.GetString("role") != "admin" {
if userRole != "cabine" && userRole != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return return
} }
err := database.CreateUser(&user) if user.Role == "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "La création d'un compte administrateur n'est pas autorisée via l'application"})
return
}
if user.Role != "livreur" && user.Role != "cabine" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Rôle invalide, valeurs acceptées : livreur, cabine"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
log.Printf("❌ [CREATE_USER] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
user.Password = string(hashed)
if err := database.CreateUser(&user); err != nil {
log.Printf("❌ [CREATE_USER] Erreur: %v", err) log.Printf("❌ [CREATE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
return return
} }
log.Printf("✅ [CREATE_USER] Utilisateur %d créé", user.ID) log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"}) c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
} }
func Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
-504
View File
@@ -1,244 +1,13 @@
// ============================================
// handlers/cabine_handlers.go - COMPLET
// INCLUT: SetCommandDestinationCoordinates
// ============================================
package handlers package handlers
import ( import (
"encoding/json"
"fmt"
"gestion/db" "gestion/db"
"gestion/utils"
"log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "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) { func GetLivreurPosition(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
livreurUsername := c.Param("username") livreurUsername := c.Param("username")
@@ -273,108 +42,6 @@ func GetLivreurPosition(c *gin.Context) {
}) })
} }
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) { func GetDeliveryIssues(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -473,53 +140,6 @@ func UpdateDeliveryIssue(c *gin.Context) {
}) })
} }
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) { func GetCommandLogs(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -543,127 +163,3 @@ func GetCommandLogs(c *gin.Context) {
"count": len(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 != "",
})
}
+6 -18
View File
@@ -1,9 +1,3 @@
// ============================================
// handlers/cancel_command_handler.go
// ANNULATION DE COMMANDES AVEC SANCTIONS ÉVOLUTIVES
// VERSION SÉCURISÉE - FIX ETA CHECK
// ============================================
package handlers package handlers
import ( import (
@@ -201,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) {
return return
} }
// ✅ AUTRES ERREURS
switch err.Error() { switch err.Error() {
case "commande non trouvée": case "commande non trouvée":
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
@@ -218,9 +211,6 @@ func CancelCommandByClient(c *gin.Context) {
return return
} }
// ============================================
// SUCCÈS
// ============================================
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID) log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
response := gin.H{ response := gin.H{
@@ -242,10 +232,6 @@ func CancelCommandByClient(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// ============================================
// HISTORIQUE DES ANNULATIONS - VERSION SÉCURISÉE
// ============================================
func GetMyCancellationHistory(c *gin.Context) { func GetMyCancellationHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -266,9 +252,12 @@ func GetMyCancellationHistory(c *gin.Context) {
return return
} }
var totalPenalty int var penaltyResult struct {
penaltyQuery := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1` Amende int `gorm:"column:amende"`
database.QueryRow(penaltyQuery).Scan(&totalPenalty) }
database.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`,
username).Scan(&penaltyResult)
totalPenalty := penaltyResult.Amende
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@@ -322,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) {
return return
} }
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
var enrichedOrders []map[string]any var enrichedOrders []map[string]any
for _, order := range cancelledOrders { for _, order := range cancelledOrders {
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"])) orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
+20
View File
@@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) {
}) })
} }
func ReorderCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
return
}
if err := database.ReorderCategories(req.IDs); err != nil {
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func DeleteCategory(c *gin.Context) { func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
+2 -1
View File
@@ -123,6 +123,7 @@ func GetMyCommandsWithTracking(c *gin.Context) {
"status_message": getStatusMessage(cmd["status"].(string)), "status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"livreur": livreurInfo, "livreur": livreurInfo,
"eta": etaData, "eta": etaData,
@@ -208,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
for _, logEntry := range logs { for _, logEntry := range logs {
status, _ := logEntry["status"].(string) status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string) message, _ := logEntry["message"].(string)
createdAt, _ := logEntry["created_at"] createdAt := logEntry["created_at"]
timeline = append(timeline, gin.H{ timeline = append(timeline, gin.H{
"status": status, "status": status,
+157 -17
View File
@@ -3,8 +3,10 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/csv" "encoding/csv"
"encoding/json"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
@@ -71,8 +73,47 @@ func validateAddress(address string) error {
return nil return nil
} }
// updateCommandDestinationCoords regéocode l'adresse et met à jour
// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
// (géocodées une seule fois à l'assignation) : la vérification GPS de
// handlers/deleviry.go compare alors la position réelle du livreur à un point
// périmé et peut refuser à tort une validation "trop loin de la destination"
// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
// de géocodage, on réinitialise les coordonnées plutôt que de laisser
// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
// bloquer sur un point qui ne correspond plus à l'adresse réelle.
func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
if geoService == nil || strings.TrimSpace(address) == "" {
return
}
location, err := geoService.GeocodeAddress(address)
if err != nil || location == nil {
log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
}
return
}
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
location.Latitude, location.Longitude, commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
return
}
log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
}
func UpdateCommandAddress(c *gin.Context) { func UpdateCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role") userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, userRole) { if !utils.CheckRoleAdmin(c, userRole) {
@@ -136,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) {
return return
} }
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
database.AddCommandLog(commandID, "address_updated", database.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername), fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
adminUsername) adminUsername)
@@ -218,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) {
// POST /api/v1/commands/:id/address/respond // POST /api/v1/commands/:id/address/respond
func RespondToAddressProposal(c *gin.Context) { func RespondToAddressProposal(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role") userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) { if !utils.CheckRoleClient(c, userRole) {
@@ -244,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) {
return return
} }
// La colonne proposed_address est vidée par RespondToAddressProposal dès
// qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
// nouvelle adresse en cas d'acceptation.
var proposedAddress string
if req.Accepted {
if command, err := database.GetCommandByID(commandID); err == nil {
proposedAddress, _ = command["proposed_address"].(string)
}
}
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil { if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
utils.ServerErr(c, "Impossible de traiter la réponse", err) utils.ServerErr(c, "Impossible de traiter la réponse", err)
return return
} }
if req.Accepted && proposedAddress != "" {
updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
}
action := "refusée" action := "refusée"
if req.Accepted { if req.Accepted {
action = "acceptée" action = "acceptée"
@@ -261,6 +319,64 @@ func RespondToAddressProposal(c *gin.Context) {
}) })
} }
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
// SQL dans db.UpdateOwnCommandAddress).
func UpdateOwnCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
clientUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
if !checkRateLimit(rateLimitKey) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
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 {
DeliveryAddress string `json:"delivery_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if !geoService.IsValidAddress(req.DeliveryAddress) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
return
}
if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Adresse mise à jour",
})
}
func ExportApprovedCommandsCSV(c *gin.Context) { func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -480,10 +596,6 @@ func StaffApproveDelivery(c *gin.Context) {
}) })
} }
// ============================================
// APPROBATION PAR ADMIN
// ============================================
func ValidateDelivery(c *gin.Context) { func ValidateDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -558,7 +670,7 @@ func ValidateDelivery(c *gin.Context) {
currentStatus, _ := command["status"].(string) currentStatus, _ := command["status"].(string)
validStatuses := []string{"assigned", "en_route", "pending", "livre"} validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, currentStatus) { if !slices.Contains(validStatuses, currentStatus) {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
"command_id": commandID, "command_id": commandID,
@@ -595,10 +707,6 @@ func ValidateDelivery(c *gin.Context) {
}) })
} }
// ============================================
// GESTION ADMIN
// ============================================
// GetAvailableDeliveryPersons récupère les livreurs disponibles // GetAvailableDeliveryPersons récupère les livreurs disponibles
// GET /api/v1/admin/delivery-persons/available // GET /api/v1/admin/delivery-persons/available
func GetAvailableDeliveryPersons(c *gin.Context) { func GetAvailableDeliveryPersons(c *gin.Context) {
@@ -634,6 +742,7 @@ func GetAvailableDeliveryPersons(c *gin.Context) {
// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."}) // Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."})
func AssignDeliveryPerson(c *gin.Context) { func AssignDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
// ✅ SÉCURITÉ: Admin ou Cabine // ✅ SÉCURITÉ: Admin ou Cabine
role := c.GetString("role") role := c.GetString("role")
@@ -683,6 +792,39 @@ func AssignDeliveryPerson(c *gin.Context) {
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername), fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
staffUsername.(string)) staffUsername.(string))
// Géocodage async : stocker les coords si absentes
go func() {
cmd, err := database.GetCommandByID(commandID)
if err != nil {
return
}
dLat, _ := cmd["dest_latitude"].(float64)
dLon, _ := cmd["dest_longitude"].(float64)
if dLat != 0 && dLon != 0 {
return // coords déjà présentes
}
adresse, _ := cmd["adresse"].(string)
if adresse == "" {
return
}
location, err := geoService.GeocodeAddress(adresse)
if err != nil || location == nil {
log.Printf("⚠️ [ASSIGN] Géocodage échoué pour cmd %d: %v", commandID, err)
return
}
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude,
"lon": location.Longitude,
})
destKey := fmt.Sprintf("command:destination:%d", commandID)
db.Redis.Set(db.RedisCtx, destKey, coordsJSON, 4*time.Hour)
database.GDB.Exec(
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
location.Latitude, location.Longitude, commandID,
)
log.Printf("📍 [ASSIGN] Coords stockées pour cmd %d: (%.6f, %.6f)", commandID, location.Latitude, location.Longitude)
}()
log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername) log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -778,13 +920,6 @@ func GetClientCommandsHistory(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// ============================================
// NOTIFICATIONS CLIENT
// ============================================
// NotifyClientToDescend envoie une notification push au client pour descendre récupérer sa commande
// POST /api/v2/admin/protected/orders/:id/notify-client
// POST /api/v1/cabine/commands/:id/notify-client
func NotifyClientToDescend(c *gin.Context) { func NotifyClientToDescend(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -1119,7 +1254,12 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
return return
} }
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { if req.Status == "cancelled" {
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
utils.ServerErr(c, "Impossible d'annuler la commande", err)
return
}
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
utils.ServerErr(c, "Impossible de mettre à jour le statut", err) utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
return return
} }
+3 -2
View File
@@ -15,8 +15,9 @@ import (
// IPNWebhook - POST /api/v1/webhooks/nowpayments // IPNWebhook - POST /api/v1/webhooks/nowpayments
func IPNWebhook(c *gin.Context) { func IPNWebhook(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient) npRaw, npExists := c.Get("nowpayments")
if !ok || np == nil { np, ok := npRaw.(*services.NowPaymentsClient)
if !npExists || !ok || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
return return
} }
+175 -24
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
@@ -38,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
return return
} }
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
commandIDs := make([]int, 0, len(commands))
clientUsernames := make([]string, 0, len(commands))
for _, cmd := range commands {
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
commandIDs = append(commandIDs, cid)
}
if u, _ := cmd["username"].(string); u != "" {
clientUsernames = append(clientUsernames, u)
}
}
allItems, _ := database.GetCommandItemsBatch(commandIDs)
allClients, _ := database.GetClientsByUsernames(clientUsernames)
filteredCommands := make([]gin.H, len(commands)) filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands { for i, cmd := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])) commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
items, _ := database.GetCommandItems(commandID) items := allItems[commandID]
// Client info SANS téléphone
clientUsername, _ := cmd["username"].(string) clientUsername, _ := cmd["username"].(string)
client, _ := database.GetClientByUsername(clientUsername) client := allClients[clientUsername]
clientInfo := gin.H{"nom": "Client", "prenom": ""} clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil { if client != nil {
@@ -61,6 +76,7 @@ func GetMyDeliveries(c *gin.Context) {
"produit": item["produit"], "produit": item["produit"],
"quantite": item["quantite"], "quantite": item["quantite"],
"prix": item["prix"], "prix": item["prix"],
"is_reward": item["is_reward"],
} }
} }
@@ -71,6 +87,7 @@ func GetMyDeliveries(c *gin.Context) {
"status": cmd["status"], "status": cmd["status"],
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"client_info": clientInfo, "client_info": clientInfo,
"items": itemsSummary, "items": itemsSummary,
@@ -141,6 +158,7 @@ func GetDeliveryDetails(c *gin.Context) {
"produit": item["produit"], "produit": item["produit"],
"quantite": item["quantite"], "quantite": item["quantite"],
"prix": item["prix"], "prix": item["prix"],
"is_reward": item["is_reward"],
} }
} }
@@ -240,23 +258,51 @@ func UpdateDeliveryStatus(c *gin.Context) {
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon) distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance) 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") log.Printf("✅ [GPS] Validation OK")
} else { } else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée") log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
} }
} }
// Mettre à jour le statut // Mettre à jour le statut.
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { // Le cas "cancelled" passe par une transaction atomique dédiée (transition +
// remboursement stock), pour empêcher tout double remboursement en cas de
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
if req.Status == "cancelled" {
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
if cancelErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
if alreadyCancelled {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande déjà annulée",
"command_id": commandID,
"status": "cancelled",
})
return
}
cancelMsg := req.Notes
if cancelMsg == "" {
cancelMsg = "Annulé par le livreur"
}
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
if prevStatus == "arrived" || prevStatus == "livre" {
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
if penalty, err := database.ApplyCancellationPenalty(clientUsername); err == nil {
log.Printf("⚠️ [CANCEL_LIVREUR] Amende %d appliquée à %s (client absent)", penalty, clientUsername)
} else {
log.Printf("⚠️ [CANCEL_LIVREUR] Erreur application amende pour %s: %v", clientUsername, err)
}
}
}
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour", "error": "Erreur mise à jour",
}) })
@@ -300,12 +346,43 @@ func UpdateDeliveryStatus(c *gin.Context) {
} }
if destLat != 0 && destLon != 0 { if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon) toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
if err := database.SetCommandETA(commandID, etaMinutes); err != nil { // Cas 1 : GPS du livreur disponible
log.Printf("⚠️ [STATUS_LIVREUR] Erreur définition ETA: %v", err) gpsLat, gpsLon, gpsErr := database.GetDeliveryPersonLocation(usernameStr)
if gpsErr == nil && gpsLat != 0 {
from := services.Coordinates{Latitude: gpsLat, Longitude: gpsLon}
eta, _, err := services.GetETAWithTraffic(from, toCoords)
if err != nil {
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
}
etaMinutes = eta
log.Printf("📍 [STATUS_LIVREUR] ETA depuis GPS livreur: %d min", etaMinutes)
} else { } else {
// Cas 2 : GPS absent → dernière adresse de livraison
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(usernameStr)
if lastErr == nil && lastLat != 0 {
from := services.Coordinates{Latitude: lastLat, Longitude: lastLon}
eta, _, err := services.GetETAWithTraffic(from, toCoords)
if err != nil {
eta = services.CalculateETA(services.CalculateDistance(from, toCoords))
}
etaMinutes = eta
log.Printf("📍 [STATUS_LIVREUR] ETA depuis dernière livraison: %d min", etaMinutes)
} else {
// Cas 3 : Aucune position disponible
etaMinutes = 30
log.Printf("⚠️ [STATUS_LIVREUR] Aucune position disponible - ETA par défaut: %d min", etaMinutes)
}
}
} else {
etaMinutes = 30
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut: %d min", etaMinutes)
}
database.SetCommandETA(commandID, etaMinutes)
log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes) log.Printf("✅ [STATUS_LIVREUR] ETA défini: %d minutes", etaMinutes)
if etaMinutes >= 60 { if etaMinutes >= 60 {
h := etaMinutes / 60 h := etaMinutes / 60
m := etaMinutes % 60 m := etaMinutes % 60
@@ -317,12 +394,6 @@ func UpdateDeliveryStatus(c *gin.Context) {
} else { } else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes) etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
} }
}
} else {
log.Printf("⚠️ [STATUS_LIVREUR] Coordonnées destination manquantes - ETA par défaut")
etaMinutes = 30
database.SetCommandETA(commandID, etaMinutes)
}
// Mettre à jour le statut du livreur en "delivering" // Mettre à jour le statut du livreur en "delivering"
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID) database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
@@ -392,7 +463,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
database.CompleteDeliveryAndProcessNext(usernameStr, commandID) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "cancelled": case "cancelled":
// Annulation par le livreur - Nettoyer la queue // Transition + remboursement stock déjà effectués atomiquement plus haut.
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID) log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
database.CompleteDeliveryAndProcessNext(usernameStr, commandID) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
@@ -472,3 +543,83 @@ func ReportDeliveryIssue(c *gin.Context) {
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType) log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue}) c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
} }
func GetMyDeliveryStats(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)
var dayRows []models.DayRowWithResult
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
return
}
var weekRows []models.WeekRow
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
return
}
var monthRows []models.MonthRow
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
return
}
var todayRow models.TodayRow
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
return
}
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
byDay := make([]gin.H, len(dayRows))
for i, r := range dayRows {
byDay[i] = gin.H{
"label": r.Day.Format("02/01"),
"count": r.Count,
"revenue": r.Revenue,
}
}
byWeek := make([]gin.H, len(weekRows))
for i, r := range weekRows {
byWeek[i] = gin.H{
"label": fmt.Sprintf("S%d", r.WeekNum),
"count": r.Count,
"revenue": r.Revenue,
}
}
byMonth := make([]gin.H, len(monthRows))
for i, r := range monthRows {
label := "?"
if r.MonthNum >= 1 && r.MonthNum <= 12 {
label = monthNames[r.MonthNum]
}
byMonth[i] = gin.H{
"label": label,
"count": r.Count,
"revenue": r.Revenue,
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"by_day": byDay,
"by_week": byWeek,
"by_month": byMonth,
"today_count": todayRow.Count,
"today_revenue": todayRow.Revenue,
})
}
+4 -28
View File
@@ -11,6 +11,7 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"time" "time"
@@ -21,7 +22,6 @@ import (
func GetDeliveryPersonDetails(c *gin.Context) { func GetDeliveryPersonDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" { if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole) log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
@@ -62,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
// Utiliser la fonction GPS existante // Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username) lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]interface{} var locationInfo map[string]any
if err == nil { if err == nil {
locationInfo = map[string]interface{}{ locationInfo = map[string]any{
"latitude": lat, "latitude": lat,
"longitude": lon, "longitude": lon,
} }
@@ -121,17 +121,9 @@ func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
return return
} }
// Valider le statut
validStatuses := []string{"available", "busy", "offline"} validStatuses := []string{"available", "busy", "offline"}
isValid := false
for _, vs := range validStatuses {
if req.Status == vs {
isValid = true
break
}
}
if !isValid { if !slices.Contains(validStatuses, req.Status) {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide", "error": "Statut invalide",
"valid_statuses": validStatuses, "valid_statuses": validStatuses,
@@ -437,12 +429,6 @@ func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
}) })
} }
// ============================================
// 🗑️ 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) { func RemoveCommandFromQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -474,9 +460,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username) 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) livreur, err := database.GetUserByUsername(username)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé") log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
@@ -491,9 +474,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// ============================================
// Vérifier que la commande existe
// ============================================
command, err := database.GetCommandByID(commandID) command, err := database.GetCommandByID(commandID)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée") log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
@@ -501,9 +481,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// ============================================
// Retirer de la queue
// ============================================
err = database.RemoveCommandFromDeliverymanQueue(username, commandID) err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
if err != nil { if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err) log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
@@ -513,7 +490,6 @@ func RemoveCommandFromQueue(c *gin.Context) {
return return
} }
// Optionnel: Réassigner la commande en "pending"
currentStatus, _ := command["status"].(string) currentStatus, _ := command["status"].(string)
if currentStatus == "assigned" || currentStatus == "en_route" { if currentStatus == "assigned" || currentStatus == "en_route" {
err = database.UpdateCommandStatus(commandID, "pending") err = database.UpdateCommandStatus(commandID, "pending")
+72 -37
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/eta_handler_corrected.go
// CORRECTION: ETA visible UNIQUEMENT après en_route
// ============================================
package handlers package handlers
import ( import (
@@ -18,6 +13,42 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// returnStaleOrUnavailable retourne le cache périmé avec le temps restant recalculé,
// ou {eta_available: false, message: "Aucune heure disponible"} si le cache est absent ou expiré.
func returnStaleOrUnavailable(commandID int, status string, etaData map[string]string) gin.H {
if len(etaData) > 0 {
if updatedAtStr, ok := etaData["updated_at"]; ok {
var updatedAt int64
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
var etaMin int64
if etaStr, ok2 := etaData["eta_minutes"]; ok2 {
fmt.Sscanf(etaStr, "%d", &etaMin)
}
elapsed := int64(time.Since(time.Unix(updatedAt, 0)).Minutes())
remaining := etaMin - elapsed
if remaining > 0 {
arrival := time.Now().Add(time.Duration(remaining) * time.Minute)
log.Printf("📦 [ETA] Cache périmé utilisé - %d min restantes", remaining)
return gin.H{
"success": true,
"command_id": commandID,
"status": status,
"eta_minutes": remaining,
"estimated_arrival": arrival.Format("15:04"),
"eta_available": true,
}
}
}
}
return gin.H{
"success": true,
"command_id": commandID,
"status": status,
"eta_available": false,
"message": "Aucune heure disponible",
}
}
func GetOrderETA(c *gin.Context) { func GetOrderETA(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -54,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
cmdUsername, _ := command["username"].(string) cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role") userRole := c.GetString("role")
@@ -81,10 +111,8 @@ func GetOrderETA(c *gin.Context) {
} }
} }
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
cmdStatus, _ := command["status"].(string) cmdStatus, _ := command["status"].(string)
// ✅ CORRECTION: Vérifier si commande terminée
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" { if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus) log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -98,21 +126,30 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// Pour pending: aucune estimation disponible if cmdStatus == "pending" || cmdStatus == "assigned" {
if cmdStatus == "pending" { log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"command_id": commandID, "command_id": commandID,
"status": cmdStatus, "status": cmdStatus,
"eta_available": false, "eta_available": false,
"message": "En attente d'assignation d'un livreur", "message": "En attente de démarrage de la livraison",
})
return
}
if cmdStatus == "arrived" {
log.Printf("️ [ETA] Commande arrived - livreur déjà sur place")
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_available": false,
"message": "Le livreur est arrivé à destination",
}) })
return 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) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result() etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
@@ -153,10 +190,8 @@ func GetOrderETA(c *gin.Context) {
} }
} }
// 7️⃣ Pas de cache valide - Recalculer l'ETA
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...") log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
// Récupérer coordonnées destination
var destLat, destLon float64 var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID) destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
@@ -183,11 +218,8 @@ func GetOrderETA(c *gin.Context) {
} }
if destLat == 0 || destLon == 0 { if destLat == 0 || destLon == 0 {
log.Printf(" [ETA] Coordonnées destination manquantes") log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
"success": false,
"error": "Coordonnées de destination manquantes",
})
return return
} }
@@ -195,26 +227,30 @@ func GetOrderETA(c *gin.Context) {
livreurAssign, _ := command["livreur_assign"].(string) livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign == "" { if livreurAssign == "" {
log.Printf("⚠️ [ETA] Aucun livreur assigné") log.Printf("⚠️ [ETA] Aucun livreur assigné")
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": true,
"error": "Aucun livreur assigné à cette commande", "command_id": commandID,
}) "status": cmdStatus,
return "eta_available": false,
} "message": "Aucune heure disponible",
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 return
} }
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon} toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Calculer ETA avec TomTom livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
if gpsErr != nil {
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
if lastErr != nil || lastLat == 0 {
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
return
}
livreurLocation = &services.Coordinates{Latitude: lastLat, Longitude: lastLon}
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
}
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)", log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude) livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
@@ -225,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
etaMinutes = services.CalculateETA(distanceKm) etaMinutes = services.CalculateETA(distanceKm)
} }
// Sauvegarder en cache
now := time.Now() now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute) arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := map[string]interface{}{ etaCache := map[string]any{
"command_id": commandID, "command_id": commandID,
"eta_minutes": etaMinutes, "eta_minutes": etaMinutes,
"updated_at": now.Unix(), "updated_at": now.Unix(),
+27 -71
View File
@@ -1,7 +1,3 @@
// ============================================
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package handlers package handlers
import ( import (
@@ -17,10 +13,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// GÉOCODAGE D'ADRESSES
// ============================================
func GeocodeAddress(c *gin.Context) { func GeocodeAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -34,7 +26,22 @@ func GeocodeAddress(c *gin.Context) {
location, err := geoService.GeocodeAddress(req.Address) location, err := geoService.GeocodeAddress(req.Address)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"}) // Tentative de correction — resolveAddress ne touche pas à c.JSON
suggestion, err := resolveAddress(geoService, req.Address)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
return
}
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
c.JSON(http.StatusOK, gin.H{
"success": true,
"latitude": suggestion.Coordinates.Latitude,
"longitude": suggestion.Coordinates.Longitude,
"display_name": suggestion.CorrectedAddress,
"correction_applied": suggestion.CorrectionApplied,
"confidence": suggestion.Confidence,
})
return return
} }
@@ -44,9 +51,15 @@ func GeocodeAddress(c *gin.Context) {
"latitude": location.Latitude, "latitude": location.Latitude,
"longitude": location.Longitude, "longitude": location.Longitude,
"display_name": location.DisplayName, "display_name": location.DisplayName,
"correction_applied": false,
}) })
} }
// resolveAddress : logique pure, sans toucher à gin.Context
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
return geoService.CorrectionService().ResolveAddress(address)
}
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse // FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
func FindNearestDeliveryPerson(c *gin.Context) { func FindNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -156,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) {
}) })
} }
// ============================================
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
// ============================================
// GetAllDeliveryDistances retourne tous les livreurs triés par distance // GetAllDeliveryDistances retourne tous les livreurs triés par distance
func GetAllDeliveryDistances(c *gin.Context) { func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -245,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) {
}) })
} }
// ============================================
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
// ============================================
func AutoAssignNearestDeliveryPerson(c *gin.Context) { func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -305,9 +311,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude) 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) destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{ coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude, "lat": location.Latitude,
@@ -337,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🚗 %d livreur(s) actif(s)", activeCount) log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
// Récupérer les livreurs actifs avec capacité disponible
activeLivreurs, err := database.GetAllActiveDeliveryPersons() activeLivreurs, err := database.GetAllActiveDeliveryPersons()
// Si aucun livreur avec capacité disponible
if err != nil || len(activeLivreurs) == 0 { if err != nil || len(activeLivreurs) == 0 {
// Cas 1: Un seul livreur actif -> pas de limite
if activeCount == 1 { if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman() singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil { if err != nil {
@@ -361,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// ✅ Passer les coordonnées à la fonction d'assignation
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address) err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -376,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance) 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée au seul livreur actif (sans limite)", "message": "Commande assignée au seul livreur actif (sans limite)",
@@ -389,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": true, "single_driver": true,
"traffic_aware": true, "traffic_aware": true,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -400,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity() allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 { if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive) 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() leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -414,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
return return
} }
// Calculer le temps de trajet avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude) travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -423,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
return return
} }
// ✅ Assigner de force avec coordonnées
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address) err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -439,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance) 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{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)", "message": "Commande assignée par distribution forcée (capacité max dépassée)",
@@ -453,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"over_capacity": true, "over_capacity": true,
"traffic_aware": true, "traffic_aware": true,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -464,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas 3: Erreur générique
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible", "error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount, "active_count": activeCount,
@@ -473,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas normal: Au moins un livreur avec capacité disponible
usernames := make([]string, len(activeLivreurs)) usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs { for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username usernames[i] = livreur.Username
} }
// Trouver le livreur le plus proche (calcul rapide)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -488,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Recalculer l'ETA avec TomTom pour plus de précision
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords) travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil { if err != nil {
// Fallback sur le calcul initial // Fallback sur le calcul initial
@@ -499,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime) 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) err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -514,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username) log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée à la queue du livreur", "message": "Commande assignée à la queue du livreur",
@@ -527,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": activeCount == 1, "single_driver": activeCount == 1,
"traffic_aware": err == nil, "traffic_aware": err == nil,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -537,12 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
} }
// ============================================
// 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) { func AutoAssignAllPendingCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -553,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
return return
} }
// Récupérer toutes les commandes pending
commands, err := database.GetAllCommands("pending", "") commands, err := database.GetAllCommands("pending", "")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -579,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
for _, cmd := range commands { for _, cmd := range commands {
commandID, ok := cmd["id"].(int) commandID, ok := cmd["id"].(int)
if !ok { if !ok {
// Essayer avec float64
if idFloat, ok := cmd["id"].(float64); ok { if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat) commandID = int(idFloat)
} else { } else {
@@ -587,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
} }
} }
// Récupérer l'adresse
address, ok := cmd["adresse"].(string) address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" { if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -597,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address) location, err := geoService.GeocodeAddress(address)
if err != nil { if err != nil {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -612,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
Longitude: location.Longitude, Longitude: location.Longitude,
} }
// Récupérer les livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons() activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 { if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -627,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
usernames[i] = livreur.Username usernames[i] = livreur.Username
} }
// Trouver le livreur le plus proche (version rapide pour assignation masse)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil { if err != nil {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -637,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Pour l'assignation en masse, on utilise le calcul rapide
travelTime := nearest.EstimatedTime travelTime := nearest.EstimatedTime
distance := nearest.Distance distance := nearest.Distance
@@ -651,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
etaData, _ := database.GetCommandETA(commandID) etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime interface{} var totalETA, waitTime any
totalETA = "N/A" totalETA = "N/A"
waitTime = "N/A" waitTime = "N/A"
@@ -682,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA) log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
} }
// Récupérer l'overview des queues
queuesOverview, _ := database.GetAllQueuesOverview() queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -698,12 +666,7 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
}) })
} }
// ============================================
// RÉCUPÉRER L'ÉTAT DES QUEUES DES LIVREURS
// ============================================
// GetAllDeliveryQueues retourne l'état de toutes les 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) { func GetAllDeliveryQueues(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -721,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
return return
} }
// Récupérer les détails de chaque livreur
var deliverymenDetails []gin.H var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result() keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
@@ -732,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
// Récupérer le statut // Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result() statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]interface{} var status map[string]any
if statusData != "" { if statusData != "" {
json.Unmarshal([]byte(statusData), &status) json.Unmarshal([]byte(statusData), &status)
} }
@@ -751,12 +713,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
}) })
} }
// ============================================
// 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) { func GetDeliverymanQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
-59
View File
@@ -13,16 +13,13 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"github.com/gin-gonic/gin" "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 // GET /api/v2/admin/protected/delivery-persons/:username/map-links
func GetDeliveryPersonMapLinks(c *gin.Context) { func GetDeliveryPersonMapLinks(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// Vérification du rôle admin
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" { if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -37,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username) log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
// Récupérer la position GPS du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username) lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil { if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err) log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
@@ -49,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return return
} }
// Validation des coordonnées
if lat == 0 && lon == 0 { if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username) log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -60,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return return
} }
// Générer les liens de cartes
mapLinks := database.GenerateMapLinks(lat, lon, username) mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon) log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
@@ -79,58 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
}) })
} }
// 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) { func GetLivreurNavLink(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
username := c.GetString("username") username := c.GetString("username")
@@ -153,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
return return
} }
// Priorité : coordonnées GPS de la destination
var wazeLink string var wazeLink string
destLat, hasLat := command["dest_latitude"].(float64) destLat, hasLat := command["dest_latitude"].(float64)
destLon, hasLon := command["dest_longitude"].(float64) destLon, hasLon := command["dest_longitude"].(float64)
+7 -109
View File
@@ -1,107 +1,21 @@
// ============================================
// handlers/history_handlers.go
// ============================================
// Gestion de l'historique des commandes terminées
package handlers package handlers
import ( import (
"fmt" "fmt"
"gestion/db" "gestion/db"
"log" "log"
"maps"
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "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 // GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed // GET /api/v1/my-commands/history/detailed
// ✅ Authentification requise (ClientMiddleware)
// ✅ Retourne les commandes approved avec tous les items
func GetMyCompletedOrdersWithItems(c *gin.Context) { func GetMyCompletedOrdersWithItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists { if !exists {
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié") log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
@@ -114,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
usernameStr := username.(string) usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr) 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) commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil { if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err) log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
@@ -124,8 +37,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
return return
} }
// ✅ Enrichir chaque commande avec ses items var enrichedCommands []map[string]any
var enrichedCommands []map[string]interface{}
for _, command := range commands { for _, command := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"])) commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
@@ -133,18 +45,15 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
continue continue
} }
// Récupérer les items de cette commande
items, err := database.GetCommandItems(commandID) items, err := database.GetCommandItems(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err) log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]interface{}{} items = []map[string]any{}
} }
// Ajouter les items à la commande // Ajouter les items à la commande
enrichedCommand := make(map[string]interface{}) enrichedCommand := make(map[string]any)
for k, v := range command { maps.Copy(enrichedCommand, command)
enrichedCommand[k] = v
}
enrichedCommand["items"] = items enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items) enrichedCommand["items_count"] = len(items)
@@ -153,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands)) log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
// ✅ Récupérer les infos client
client, err := database.GetClientByUsername(usernameStr) client, err := database.GetClientByUsername(usernameStr)
response := gin.H{ response := gin.H{
@@ -177,14 +85,9 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
c.JSON(http.StatusOK, response) 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) { func GetOrderHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists { if !exists {
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié") log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
@@ -196,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
usernameStr := username.(string) usernameStr := username.(string)
// Récupérer l'ID de la commande
var commandID int var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil { if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -207,7 +109,6 @@ func GetOrderHistory(c *gin.Context) {
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr) 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) command, err := database.GetCommandByID(commandID)
if err != nil { if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée") log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
@@ -217,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
return return
} }
// ✅ Vérifier que la commande appartient au client
cmdUsername, ok := command["username"].(string) cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr { if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr) log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
@@ -227,18 +127,16 @@ func GetOrderHistory(c *gin.Context) {
return return
} }
// ✅ Récupérer les logs de la commande
logs, err := database.GetCommandLogs(commandID) logs, err := database.GetCommandLogs(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err) log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]interface{}{} logs = []map[string]any{}
} }
// ✅ Récupérer les items
items, err := database.GetCommandItems(commandID) items, err := database.GetCommandItems(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err) log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
items = []map[string]interface{}{} items = []map[string]any{}
} }
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items)) log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
@@ -0,0 +1,80 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type loginHistoryWeek struct {
Week int `json:"week"`
Entries []db.LoginHistoryEntry `json:"entries"`
}
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
func GetLivreurLoginHistory(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
now := time.Now()
year := now.Year()
month := int(now.Month())
if y := c.Query("year"); y != "" {
parsed, err := strconv.Atoi(y)
if err != nil || parsed < 2000 || parsed > 2100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
return
}
year = parsed
}
if m := c.Query("month"); m != "" {
parsed, err := strconv.Atoi(m)
if err != nil || parsed < 1 || parsed > 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
return
}
month = parsed
}
database := c.MustGet("database").(*db.Database)
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
if err != nil {
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
return
}
weekOrder := make([]int, 0)
weekMap := make(map[int]*loginHistoryWeek)
for _, e := range entries {
_, isoWeek := e.CreatedAt.ISOWeek()
w, ok := weekMap[isoWeek]
if !ok {
w = &loginHistoryWeek{Week: isoWeek}
weekMap[isoWeek] = w
weekOrder = append(weekOrder, isoWeek)
}
w.Entries = append(w.Entries, e)
}
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
for _, wk := range weekOrder {
weeks = append(weeks, weekMap[wk])
}
c.JSON(http.StatusOK, gin.H{
"username": username,
"year": year,
"month": month,
"weeks": weeks,
"count": len(entries),
})
}
+2 -3
View File
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
notifKey := "notifications:" + username notifKey := "notifications:" + username
// Récupérer toutes les notifications (max 50)
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil { if err != nil {
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err) log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
markedCount := 0 markedCount := 0
for i, raw := range results { for i, raw := range results {
var n map[string]interface{} var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil { if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue continue
} }
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
// Réécrire chaque notification avec read=true // Réécrire chaque notification avec read=true
markedCount := 0 markedCount := 0
for i, raw := range results { for i, raw := range results {
var n map[string]interface{} var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil { if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue continue
} }
+75 -101
View File
@@ -1,7 +1,3 @@
// ============================================
// handlers/basket_handlers_CORRIGES.go
// ============================================
package handlers package handlers
import ( import (
@@ -12,6 +8,8 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -24,9 +22,6 @@ type BasketsRequest struct {
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
} }
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add // POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) { func AddProductsBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -48,41 +43,38 @@ func AddProductsBasket(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return return
} }
if req.ProductID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
return
}
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable) if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
if req.ProductID > 0 || req.NameProduct == "" || req.Category == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
stock, err := database.GetProductStockByID(req.ProductID) return
}
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
if err != nil { if err != nil {
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err) log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) if err.Error() == "stock insuffisant" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
return return
} }
if stock < req.Quantity { if strings.Contains(err.Error(), "prix introuvable") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock}) c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
return 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) utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
return return
} }
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier}) c.JSON(http.StatusOK, gin.H{
return "success": true,
} "message": "Produit ajouté au panier avec succès",
"panier": panier,
})
} }
// ============================================
// ============================================
// GET /api/v1/panier/:username
// Récupère le panier du client authentifié
func GetAllBaskets(c *gin.Context) { func GetAllBaskets(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
username := c.Param("username") username := c.Param("username")
@@ -95,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT") log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
@@ -105,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
authUsernameStr := authUsername.(string) authUsernameStr := authUsername.(string)
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
if username != authUsernameStr { if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!") log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr) log.Printf(" Username du JWT: %s", authUsernameStr)
@@ -116,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
username = authUsernameStr username = authUsernameStr
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
_, err := database.GetClientByUsername(username) _, err := database.GetClientByUsername(username)
if err != nil { if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username) log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
@@ -135,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
var totalAmount float64 var totalAmount float64
for _, item := range baskets { for _, item := range baskets {
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) totalAmount += item.Price
} }
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
@@ -149,11 +137,6 @@ func GetAllBaskets(c *gin.Context) {
}) })
} }
// ============================================
// ✅ SÉCURISÉ: DeleteProductFromBasket
// ============================================
// DELETE /api/v1/panier/remove
// Supprime un produit du panier
func DeleteProductFromBasket(c *gin.Context) { func DeleteProductFromBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -166,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT") log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
@@ -178,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) {
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr) 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) itemUsername, err := database.GetBasketItemOwner(req.ID)
if err != nil { if err != nil {
@@ -197,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return return
} }
// Supprimer l'article
err = database.DeleteProductFromBasket(req.ID) err = database.DeleteProductFromBasket(req.ID)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur lors de la suppression", err) utils.ServerErr(c, "Erreur lors de la suppression", err)
@@ -209,19 +189,12 @@ func DeleteProductFromBasket(c *gin.Context) {
"success": true, "success": true,
"message": "Produit supprimé du panier avec succès", "message": "Produit supprimé du panier avec succès",
"item_id": req.ID, "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) { func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT") log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
@@ -252,7 +225,6 @@ func ClearBasket(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Panier vidé avec succès", "message": "Panier vidé avec succès",
"stock_released": len(baskets),
}) })
} }
@@ -268,11 +240,19 @@ func ValidateBasket(c *gin.Context) {
} }
usernameStr := username.(string) usernameStr := username.(string)
lockKey := fmt.Sprintf("checkout_lock:%s", usernameStr)
locked, errLock := db.Redis.SetNX(db.RedisCtx, lockKey, "1", 30*time.Second).Result()
if errLock != nil || !locked {
c.JSON(http.StatusConflict, gin.H{"error": "Un checkout est déjà en cours pour ce compte"})
return
}
defer db.Redis.Del(db.RedisCtx, lockKey)
var req struct { var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"` DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"` UseReferralBalance bool `json:"use_referral_balance"`
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto" PaymentMethod string `json:"payment_method"`
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto) PayCurrency string `json:"pay_currency"`
} }
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" { if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
@@ -286,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
} }
req.DeliveryAddress = cmd.DeliveryAddress 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 services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked { 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"}) c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
@@ -296,9 +275,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr) log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
// ============================================
// 1️⃣ Vérifier que le panier n'est pas vide
// ============================================
items, err := database.GetBasketItems(usernameStr) items, err := database.GetBasketItems(usernameStr)
if err != nil { if err != nil {
utils.ServerErr(c, "Impossible de récupérer le panier", err) utils.ServerErr(c, "Impossible de récupérer le panier", err)
@@ -313,9 +289,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 1️⃣b Vérifier le minimum de commande selon la zone
// ============================================
var cartTotal float64 var cartTotal float64
for _, item := range items { for _, item := range items {
if price, ok := item["price"].(float64); ok { if price, ok := item["price"].(float64); ok {
@@ -323,10 +296,20 @@ func ValidateBasket(c *gin.Context) {
} }
} }
// Récupérer les paramètres globaux (zones + parrainage) hasRewardItem := false
for _, item := range items {
if price, ok := item["price"].(float64); ok && price == 0 {
hasRewardItem = true
break
}
}
if hasRewardItem && cartTotal <= 0 {
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
return
}
appSettings, _ := database.GetSettings() appSettings, _ := database.GetSettings()
// Récupérer le solde parrainage disponible (seulement si le système est activé)
var referralBalance float64 var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled { if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr) referralBalance, _ = database.GetClientReferralBalance(usernameStr)
@@ -360,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
return 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 var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 { if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance effectivePayment := cartTotal - referralBalance
@@ -384,9 +365,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed) 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 referralUsed > 0 {
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil { if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err) log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
@@ -396,11 +374,25 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
} }
// Vérification option crypto unavailable, err := database.GetUnavailableBasketItems(usernameStr)
if err != nil {
utils.ServerErr(c, "Erreur vérification produits", err)
return
}
if len(unavailable) > 0 {
log.Printf("❌ [CHECKOUT] Produits sans prix actif: %v", unavailable)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Certains produits de votre panier ne sont plus disponibles",
"products": unavailable,
})
return
}
isCrypto := req.PaymentMethod == "crypto" isCrypto := req.PaymentMethod == "crypto"
if isCrypto { if isCrypto {
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient) npRaw, npExists := c.Get("nowpayments")
if !npOk || np == nil { np, npOk := npRaw.(*services.NowPaymentsClient)
if !npExists || !npOk || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
return return
} }
@@ -416,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
_ = database.CreditClientReferral(usernameStr, referralUsed) _ = database.CreditClientReferral(usernameStr, referralUsed)
} }
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err) log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
if strings.Contains(err.Error(), "stock insuffisant") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
return return
} }
@@ -429,9 +425,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// ============================================
// PAIEMENT CRYPTO - créer le paiement NowPayments
// ============================================
if isCrypto { if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient) np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c)) ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
@@ -444,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
} }
payResp, err := np.CreatePayment(payReq) payResp, err := np.CreatePayment(payReq)
if err != nil { if err != nil {
// Annuler la commande et restaurer le panier / parrainage
_ = database.CancelCryptoCommand(commandID) _ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 { if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed) _ = database.CreditClientReferral(usernameStr, referralUsed)
@@ -454,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
return 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 { 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) log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
} }
priceAmt, _ := payResp.PriceAmount.Float64() priceAmt, _ := payResp.PriceAmount.Float64()
payAmt, _ := payResp.PayAmount.Float64() payAmt, _ := payResp.PayAmount.Float64()
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt) if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
return
}
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency) log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
@@ -479,22 +478,8 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) 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 assigned bool
var assignInfo gin.H var assignInfo gin.H
@@ -514,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
if err == nil { if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance) 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( travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location, nearest.Location,
services.Coordinates{ services.Coordinates{
@@ -532,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance) log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
// Assigner la commande au livreur
err = database.AssignCommandToDeliverymanQueueWithCoords( err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID, commandID,
nearest.Username, nearest.Username,
@@ -545,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
if err != nil { if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else { } else {
// Mettre à jour le statut du livreur
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) 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) notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 { if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed) notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
@@ -559,8 +539,6 @@ func ValidateBasket(c *gin.Context) {
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
} }
// Notifier le client
clientOrderID := database.GetClientOrderID(commandID) 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) 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) database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
@@ -583,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
} }
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr) newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{ resp := gin.H{
"success": true, "success": true,
@@ -612,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
c.JSON(http.StatusCreated, resp) c.JSON(http.StatusCreated, resp)
} }
// getBaseURL construit l'URL de base depuis la requête en cours
func getBaseURL(c *gin.Context) string { func getBaseURL(c *gin.Context) string {
scheme := "https" scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
+9 -15
View File
@@ -9,8 +9,6 @@ import (
"github.com/gin-gonic/gin" "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) { func SetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username") targetUsername := c.Param("username")
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
return return
} }
// Vérifier que le parrain existe
parrain, err := database.GetClientByUsername(req.Parrain) parrain, err := database.GetClientByUsername(req.Parrain)
if err != nil || parrain == nil { if err != nil || parrain == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"}) c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
return return
} }
// Vérifier que le client n'a pas déjà un parrain
existing, err := database.GetClientParrain(targetUsername) existing, err := database.GetClientParrain(targetUsername)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur vérification parrain", err) utils.ServerErr(c, "Erreur vérification parrain", err)
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
return return
} }
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil { settings, _ := database.GetSettings()
creditAmount := 0.0
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
creditAmount = settings.ReferralAmount
}
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
utils.ServerErr(c, "Erreur enregistrement parrain", err) utils.ServerErr(c, "Erreur enregistrement parrain", err)
return return
} }
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
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{ c.JSON(http.StatusOK, gin.H{
"message": "Parrain enregistré", "message": "Parrain enregistré",
"client": targetUsername, "client": targetUsername,
"parrain": req.Parrain, "parrain": req.Parrain,
"amount_credited": settings.ReferralAmount, "amount_credited": creditAmount,
}) })
} }
+428
View File
@@ -0,0 +1,428 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
"math"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
func normalizeRewardCategoryType(t string) string {
if t == "half_price_product" {
return "half_price_product"
}
return "free_product"
}
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
// reward.RewardItems sont éligibles et avec quel type de récompense
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
// doit faire partie des catégories du pool, soit par whitelist explicite
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
eligible := make(map[int]string)
if reward == nil {
return eligible
}
for _, cfg := range reward.CategoryConfigs {
if !poolCategories[cfg.Category] {
continue
}
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
for pid, cat := range productCategories {
if cat == cfg.Category {
eligible[pid] = rewardType
}
}
} else {
for _, pid := range cfg.ProductIDs {
eligible[pid] = rewardType
}
}
}
return eligible
}
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
for _, cfg := range reward.CategoryConfigs {
matches := false
if cfg.AllProducts {
matches = cfg.Category == productCategory
} else {
for _, pid := range cfg.ProductIDs {
if pid == productID {
matches = true
break
}
}
}
if matches {
return normalizeRewardCategoryType(cfg.Type)
}
}
return "free_product"
}
// effectiveRewardPrice calcule le prix réellement facturé pour un item
// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
// prix catalogue actif (palier correspondant à la quantité) pour
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
// être proposée/réclamée plutôt que de facturer un montant incorrect.
func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) {
if rewardType != "half_price_product" {
return 0, nil
}
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
if err != nil {
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
}
return math.Round(catalogPrice/2*100) / 100, nil
}
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
func GetMyPointsRewards(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)
settings, err := database.GetSettings()
if err != nil {
utils.ServerErr(c, "Erreur lecture paramètres", err)
return
}
if !settings.PointsEnabled || len(settings.PointsPools) == 0 {
c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil})
return
}
pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username)
if err != nil {
utils.ServerErr(c, "Erreur lecture points", err)
return
}
reward := settings.PointsReward
type EligibleConfigResponse struct {
Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"`
}
type RewardItemResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Type string `json:"type"`
}
type PoolInfo struct {
Key string `json:"key"`
Name string `json:"name"`
Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"`
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
}
// Collecter tous les product_ids nécessaires en un seul passage
allProductIDs := make([]int, 0)
if reward != nil {
for _, cfg := range reward.CategoryConfigs {
if !cfg.AllProducts {
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
}
}
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
allProductIDs = append(allProductIDs, item.ProductID)
}
}
}
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
pools := make([]PoolInfo, 0, len(settings.PointsPools))
for _, pool := range settings.PointsPools {
pts := pointsExtra[pool.Key]
redeemed := pointsRedeemed[pool.Key]
var earned, available int
if reward != nil && reward.Threshold > 0 {
earned = pts / reward.Threshold
available = earned - redeemed
available = max(earned-redeemed, 0)
}
poolCats := make(map[string]bool, len(pool.Categories))
for _, c := range pool.Categories {
poolCats[c] = true
}
eligibleConfigs := make([]EligibleConfigResponse, 0)
if reward != nil {
for _, cfg := range reward.CategoryConfigs {
if !poolCats[cfg.Category] {
continue
}
names := make([]string, 0, len(cfg.ProductIDs))
for _, pid := range cfg.ProductIDs {
if n, ok := productNames[pid]; ok {
names = append(names, n)
}
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs,
ProductNames: names,
})
}
}
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil {
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: productNames[item.ProductID],
Quantity: item.Quantity,
Price: price,
Type: rewardType,
})
}
}
pools = append(pools, PoolInfo{
Key: pool.Key,
Name: pool.Name,
Points: pts,
RewardsEarned: earned,
RewardsClaimed: redeemed,
RewardsAvailable: available,
EligibleConfigs: eligibleConfigs,
EligibleRewardItems: eligibleRewardItems,
})
}
// Construire la liste des produits récompense avec leurs noms (aperçu
// global, indépendant d'un pool précis — le type/prix effectif par pool
// est celui exposé dans pools[].eligible_reward_items).
var rewardMeta gin.H
if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID <= 0 {
continue
}
rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
}
name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: name,
Quantity: item.Quantity,
Price: price,
Type: rewardType,
})
}
rewardMeta = gin.H{
"threshold": reward.Threshold,
"description": reward.Description,
"reward_items": rewardItems,
}
}
c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta})
}
// ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil.
func ClaimMyReward(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
var req struct {
PoolKey string `json:"pool_key" binding:"required"`
ProductID int `json:"product_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
return
}
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
utils.ServerErr(c, "Erreur lecture paramètres", err)
return
}
if !settings.PointsEnabled {
c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"})
return
}
reward := settings.PointsReward
if reward == nil || reward.Threshold <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"})
return
}
// Vérifier que le pool existe et récupérer ses catégories
var selectedPool *models.PointsPool
for i := range settings.PointsPools {
if settings.PointsPools[i].Key == req.PoolKey {
selectedPool = &settings.PointsPools[i]
break
}
}
if selectedPool == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
return
}
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
// fait partie des catégories du pool (via CategoryConfigs) — sans ce
// filtre, un client pourrait réclamer n'importe quel produit récompense
// (toutes catégories confondues) avec les points d'un pool quelconque.
poolCategories := make(map[string]bool, len(selectedPool.Categories))
for _, cat := range selectedPool.Categories {
poolCategories[cat] = true
}
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
rewardProductIDs = append(rewardProductIDs, item.ProductID)
}
}
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
if err != nil {
utils.ServerErr(c, "Erreur lecture catégories produits", err)
return
}
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
// palier de prix actif), la réclamation entière échoue proprement, avant
// même de démarrer la transaction de consommation de points.
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
log.Printf("❌ [CLAIM] %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
item.Price = price
eligibleItems = append(eligibleItems, item)
}
itemsToAdd := eligibleItems
if req.ProductID > 0 {
itemsToAdd = nil
for _, item := range eligibleItems {
if item.ProductID == req.ProductID {
itemsToAdd = []models.RewardItem{item}
break
}
}
if itemsToAdd == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
return
}
}
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") {
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
return
}
if strings.Contains(err.Error(), "produit récompense introuvable") {
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
utils.ServerErr(c, "Erreur réclamation récompense", err)
return
}
productAdded := len(added) > 0
var productNames []string
for _, item := range added {
productNames = append(productNames, item.ProductName)
}
if productAdded {
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"description": reward.Description,
"remaining_rewards": remaining,
"product_added": productAdded,
"product_names": productNames,
})
}
func AdminResetClientRedeemed(c *gin.Context) {
username := c.Param("username")
poolKey := c.Query("pool_key")
database := c.MustGet("database").(*db.Database)
if err := database.ResetClientRedeemed(username, poolKey); err != nil {
utils.ServerErr(c, "Erreur reset récompenses", err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+255 -200
View File
@@ -4,12 +4,12 @@ import (
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -17,10 +17,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// CONFIGURATION & LIMITES
// ============================================
const ( const (
MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier MaxFileSize = 10 * 1024 * 1024 // 10MB par fichier
MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total MaxTotalUploadSize = 50 * 1024 * 1024 // 50MB total
@@ -30,7 +26,6 @@ const (
MaxProductsPerUser = 100 // Limite pour éviter spam MaxProductsPerUser = 100 // Limite pour éviter spam
) )
// ✅ MIME types autorisés (vérification réelle du contenu)
var allowedMimeTypes = map[string]bool{ var allowedMimeTypes = map[string]bool{
"image/jpeg": true, "image/jpeg": true,
"image/png": true, "image/png": true,
@@ -41,28 +36,6 @@ var allowedMimeTypes = map[string]bool{
"video/quicktime": 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 { func validateProductName(name string) error {
if len(name) == 0 { if len(name) == 0 {
return fmt.Errorf("nom requis") return fmt.Errorf("nom requis")
@@ -143,7 +116,6 @@ func validateCategory(database *db.Database, category string) error {
return nil return nil
} }
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
@@ -157,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
} }
mimeType := mtype.String() mimeType := mtype.String()
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
if idx := strings.Index(mimeType, ";"); idx != -1 { if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx]) mimeType = strings.TrimSpace(mimeType[:idx])
} }
@@ -169,32 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
return mimeType, nil 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) { func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -203,14 +151,12 @@ func CreateProduct(c *gin.Context) {
username, _ := safeGetUsername(c) username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil { if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err) log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return return
} }
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name")) name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category")) category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description")) description := strings.TrimSpace(c.PostForm("description"))
@@ -220,7 +166,6 @@ func CreateProduct(c *gin.Context) {
unit = "u" unit = "u"
} }
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil { if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -231,7 +176,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category)) category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune { category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 { if r < 32 || r == 127 {
@@ -250,7 +194,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64) stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
@@ -262,13 +205,13 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{} prices := []models.ProductPrice{}
priceIndex := 0 priceIndex := 0
for priceIndex < 100 { // Limite anti-spam for priceIndex < 100 {
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex) quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex) priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
quantityStr := c.PostForm(quantityKey) quantityStr := c.PostForm(quantityKey)
priceStr := c.PostForm(priceKey) priceStr := c.PostForm(priceKey)
@@ -294,9 +237,13 @@ func CreateProduct(c *gin.Context) {
return return
} }
activePriceStr := c.PostForm(activePriceKey)
activePrice := activePriceStr != "false"
prices = append(prices, models.ProductPrice{ prices = append(prices, models.ProductPrice{
Quantity: quantity, Quantity: quantity,
Price: price, Price: price,
ActivePrice: activePrice,
}) })
priceIndex++ priceIndex++
@@ -309,6 +256,8 @@ func CreateProduct(c *gin.Context) {
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name) log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
comingSoon := c.PostForm("coming_soon") == "true"
// ✅ CRÉER LE PRODUIT // ✅ CRÉER LE PRODUIT
product := models.Product{ product := models.Product{
Name: name, Name: name,
@@ -316,6 +265,7 @@ func CreateProduct(c *gin.Context) {
Description: description, Description: description,
Stock: stock, Stock: stock,
Unit: unit, Unit: unit,
ComingSoon: comingSoon,
Prices: prices, Prices: prices,
} }
@@ -346,7 +296,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct { if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -359,13 +308,13 @@ func CreateProduct(c *gin.Context) {
cleanProductName := cleanFileName(product.Name) cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{} uploadedMedia := []models.Media{}
savedFiles := []string{} savedFiles := []models.Media{}
storage := c.MustGet("storage").(services.Storage)
var totalSize int64 = 0 var totalSize int64 = 0
for i, fileHeader := range files { for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize { if fileHeader.Size > MaxFileSize {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)), "error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
@@ -374,10 +323,8 @@ func CreateProduct(c *gin.Context) {
} }
totalSize += fileHeader.Size totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize { if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)), "error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
@@ -387,76 +334,50 @@ func CreateProduct(c *gin.Context) {
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename) 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) mimeType, err := validateFileMimeType(fileHeader)
if err != nil { if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err) log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return return
} }
// ✅ DÉTERMINER LE TYPE DE MÉDIA
var mediaType string var mediaType string
if strings.HasPrefix(mimeType, "image/") { if strings.HasPrefix(mimeType, "image/") {
mediaType = "image" mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") { } else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video" mediaType = "video"
} else { } else {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return return
} }
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
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 { 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) log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return return
} }
savedFiles = append(savedFiles, safeFilePath) savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{ media := models.Media{
ProductID: product.ID, ProductID: product.ID,
Type: mediaType, Type: mediaType,
URL: mediaURL, URL: mediaURL,
Key: mediaKey,
} }
if err := database.CreateMedia(&media); err != nil { if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err) log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return return
@@ -475,10 +396,6 @@ func CreateProduct(c *gin.Context) {
}) })
} }
// ============================================
// GET ENDPOINTS - SÉCURISÉS (lecture publique OK)
// ============================================
func GetAllProducts(c *gin.Context) { func GetAllProducts(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -491,7 +408,10 @@ func GetAllProducts(c *gin.Context) {
}) })
return return
} }
role := c.GetString("role")
if role != "admin" && role != "cabine" {
products = filterActivePrices(products)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -504,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category"))) category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(database, category); err != nil { if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
@@ -523,12 +442,14 @@ func GetProductsByCategory(c *gin.Context) {
return return
} }
// ✅ Charger les médias
for i := range products { for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID) media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media products[i].Media = media
} }
roleCtx := c.GetString("role")
if roleCtx != "admin" && roleCtx != "cabine" {
products = filterActivePrices(products)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": products, "data": products,
@@ -538,7 +459,6 @@ func GetProductsByCategory(c *gin.Context) {
func GetProductByID(c *gin.Context) { func GetProductByID(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 { if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -547,7 +467,6 @@ func GetProductByID(c *gin.Context) {
}) })
return return
} }
product, err := database.GetProductByID(id) product, err := database.GetProductByID(id)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -556,25 +475,23 @@ func GetProductByID(c *gin.Context) {
}) })
return return
} }
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID) media, _ := database.GetMediaByProductID(product.ID)
product.Media = media product.Media = media
role := c.GetString("role")
if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product)
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": product, "data": product,
}) })
} }
// ============================================
// UPDATE PRODUCT - VERSION SÉCURISÉE
// ============================================
func UpdateProduct(c *gin.Context) { func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -589,7 +506,6 @@ func UpdateProduct(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id) _, err = database.GetProductByID(id)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -600,9 +516,10 @@ func UpdateProduct(c *gin.Context) {
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` Category string `json:"category"`
Description string `json:"description"` Description string `json:"description"`
Stock float64 `json:"stock"`
Unit string `json:"unit"` Unit string `json:"unit"`
Prices []models.ProductPrice `json:"prices"` Prices []models.ProductPrice `json:"prices"`
Stock *float64 `json:"stock"`
ComingSoon *bool `json:"coming_soon"`
} }
if err := c.ShouldBindJSON(&updateData); err != nil { if err := c.ShouldBindJSON(&updateData); err != nil {
@@ -610,7 +527,6 @@ func UpdateProduct(c *gin.Context) {
return return
} }
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil { if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -629,12 +545,8 @@ func UpdateProduct(c *gin.Context) {
if updateData.Unit == "" { if updateData.Unit == "" {
updateData.Unit = "u" 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 { if err := validateUnit(updateData.Unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -651,14 +563,32 @@ func UpdateProduct(c *gin.Context) {
} }
} }
if updateData.Stock != nil {
if err := validateStock(*updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
log.Printf("🔄 [UpdateProduct] %s met à jour produit #%d", username, id) 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 { comingSoon := false
if updateData.ComingSoon != nil {
comingSoon = *updateData.ComingSoon
}
if err := database.UpdateProduct(id, updateData.Name, updateData.Category, updateData.Description, updateData.Unit, comingSoon, updateData.Prices); err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err) log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return return
} }
if updateData.Stock != nil {
if err := database.SetProductStock(id, *updateData.Stock); err != nil {
log.Printf("⚠️ [UpdateProduct] Erreur mise à jour stock: %v", err)
}
}
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR // ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
updatedProduct, _ := database.GetProductByID(id) updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id) media, _ := database.GetMediaByProductID(id)
@@ -672,10 +602,76 @@ func UpdateProduct(c *gin.Context) {
}) })
} }
func DeleteMedia(c *gin.Context) { func UpdateStock(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE role := c.GetString("role")
if role != "admin" {
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 req struct {
Stock float64 `json:"stock"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if err := validateStock(req.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
reserved, err := database.GetReservedQuantityInBaskets(id)
if err != nil {
utils.ServerErr(c, "Erreur lecture réservations", err)
return
}
if req.Stock+reserved < reserved {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
return
}
log.Printf("🔄 [UpdateStock] %s met à jour le stock #%d (réservé en paniers: %.3f)", username, id, reserved)
if err := database.SetProductStock(id, req.Stock); err != nil {
log.Printf("❌ [UpdateProduct] Erreur UPDATE: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
updatedProduct, _ := database.GetProductByID(id)
media, _ := database.GetMediaByProductID(id)
updatedProduct.Media = media
log.Printf("✅ [UpdateStock] le stock #%d est mis à jour", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"product": updatedProduct,
"reserved_in_baskets": reserved,
})
}
func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
s3Service := c.MustGet("s3Service").(*services.S3Service)
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -694,28 +690,27 @@ func DeleteMedia(c *gin.Context) {
return return
} }
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION if err := database.DeleteMedia(mediaID); err != nil {
filePath := strings.TrimPrefix(media.URL, "/") log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return return
} }
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
}
} else {
localStorage := services.NewLocalStorage("uploads")
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Média supprimé", "message": "Média supprimé",
@@ -725,7 +720,6 @@ func DeleteMedia(c *gin.Context) {
func UploadMedia(c *gin.Context) { func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c) username, err := safeGetUsername(c)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
@@ -733,19 +727,17 @@ func UploadMedia(c *gin.Context) {
} }
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return return
} }
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
productID, err := strconv.Atoi(c.Param("id")) productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 { if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return return
} }
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
productName, err := database.GetProductNameByID(productID) productName, err := database.GetProductNameByID(productID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -754,7 +746,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName) 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") fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" { if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
@@ -768,7 +759,6 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize { if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -777,7 +767,6 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER LE TYPE MIME RÉEL
detectedMime, err := validateFileMimeType(file) detectedMime, err := validateFileMimeType(file)
if err != nil { if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err) log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
@@ -787,7 +776,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime) 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/") { if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return return
@@ -797,40 +785,32 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ GÉNÉRER UN NOM UNIQUE
cleanProductName := cleanFileName(productName) cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
// ✅ CRÉER LE DOSSIER storage := c.MustGet("storage").(services.Storage)
destFolder := filepath.Join("uploads", fileType+"s") folder := fileType + "s"
if err := os.MkdirAll(destFolder, 0755); err != nil { mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"}) log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
return return
} }
// ✅ SAUVEGARDER LE FICHIER log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
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{ media := models.Media{
ProductID: productID, ProductID: productID,
Type: fileType, Type: fileType,
URL: mediaURL, URL: mediaURL,
Key: mediaKey,
} }
err = database.CreateMedia(&media) err = database.CreateMedia(&media)
if err != nil { if err != nil {
// Rollback: supprimer le fichier if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
os.Remove(filePath) log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
}
log.Printf("❌ [UploadMedia] Erreur DB: %v", err) log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return return
@@ -849,9 +829,71 @@ func UploadMedia(c *gin.Context) {
}) })
} }
// ============================================ func ServeMedia(c *gin.Context) {
// DELETE PRODUCT - VERSION SÉCURISÉE s3Service := c.MustGet("s3Service").(*services.S3Service)
// ============================================
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
return
}
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
io.Copy(c.Writer, body)
}
func ActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
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.AddActivePrice(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Prix activé avec succès"})
}
func DesActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
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.DeActivePrice(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
}
func DeleteProduct(c *gin.Context) { func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -873,32 +915,28 @@ func DeleteProduct(c *gin.Context) {
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id) log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id) mediaList, err := database.GetMediaByProductID(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return return
} }
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ s3Service := c.MustGet("s3Service").(*services.S3Service)
localStorage := services.NewLocalStorage("uploads")
for _, media := range mediaList { for _, media := range mediaList {
filePath := strings.TrimPrefix(media.URL, "/") if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
safeFilePath, err := sanitizeFilePath(filePath) log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
if err != nil { }
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err) } else {
continue if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
} }
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) database.DeleteMediaByProductID(id)
// ✅ SUPPRIMER LE PRODUIT
err = database.DeleteProduct(id) err = database.DeleteProduct(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
@@ -913,17 +951,11 @@ func DeleteProduct(c *gin.Context) {
}) })
} }
// ============================================ func rollbackFiles(storage services.Storage, files []models.Media) {
// HELPERS for _, f := range files {
// ============================================ if err := storage.Delete(f.URL, f.Key); err != nil {
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
} }
os.Remove(safeFilePath)
} }
} }
@@ -947,3 +979,26 @@ func cleanFileName(name string) string {
return result return result
} }
func filterActivePrices(products []models.Product) []models.Product {
for i := range products {
activePrices := []models.ProductPrice{}
for _, p := range products[i].Prices {
if p.ActivePrice {
activePrices = append(activePrices, p)
}
}
products[i].Prices = activePrices
}
return products
}
func filterActivepricesSingle(product *models.Product) {
activePrices := []models.ProductPrice{}
for _, p := range product.Prices {
if p.ActivePrice {
activePrices = append(activePrices, p)
}
}
product.Prices = activePrices
}
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func SubmitLivreurRating(c *gin.Context) {
clientUsername := c.GetString("username")
if clientUsername == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
orderID, err := strconv.Atoi(c.Param("id"))
if err != nil || orderID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
return
}
var req struct {
Rating int `json:"rating" binding:"required,min=1,max=5"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
return
}
database := c.MustGet("database").(*db.Database)
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur lecture commande", err)
return
}
if ownerUsername == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
return
}
if ownerUsername != clientUsername {
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
return
}
if livreurUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
return
}
existing, err := database.GetOrderRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur vérification avis", err)
return
}
if existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
return
}
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
utils.ServerErr(c, "Erreur enregistrement avis", err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func GetLivreurRatings(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
database := c.MustGet("database").(*db.Database)
ratings, avg, err := database.GetLivreurRatings(username)
if err != nil {
utils.ServerErr(c, "Erreur récupération avis", err)
return
}
c.JSON(http.StatusOK, gin.H{
"ratings": ratings,
"average": avg,
"count": len(ratings),
})
}
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
func GetMyRatings(c *gin.Context) {
username := c.GetString("username")
if username == "" || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
database := c.MustGet("database").(*db.Database)
ratings, avg, err := database.GetLivreurRatings(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
return
}
c.JSON(http.StatusOK, gin.H{
"ratings": ratings,
"average": avg,
"count": len(ratings),
})
}
func GetOrderRatingStatus(c *gin.Context) {
clientUsername := c.GetString("username")
if clientUsername == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
orderID, err := strconv.Atoi(c.Param("id"))
if err != nil || orderID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
database := c.MustGet("database").(*db.Database)
rating, err := database.GetOrderRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur", err)
return
}
if rating == nil {
c.JSON(http.StatusOK, gin.H{"rated": false})
return
}
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
}
+20 -271
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/redis_handlers.go - VERSION FINALE
// UTILISE UNIQUEMENT LES MÉTHODES DB PostgreSQL
// ============================================
package handlers package handlers
import ( import (
@@ -13,6 +8,7 @@ import (
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -20,10 +16,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// ============================================
// GESTION DE LA FILE DE COMMANDES
// ============================================
func validatePenaltyPoints(points int) error { func validatePenaltyPoints(points int) error {
if points <= 0 { if points <= 0 {
return fmt.Errorf("points invalides: %d (doit être > 0)", points) return fmt.Errorf("points invalides: %d (doit être > 0)", points)
@@ -47,72 +39,6 @@ func sanitizeReason(reason string) string {
return strings.TrimSpace(reason) return strings.TrimSpace(reason)
} }
// GetCommandQueue récupère toutes les commandes en attente dans la file Redis
// GET /api/v2/admin/protected/queue/pending
func GetCommandQueue(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
}
nextCommand, err := database.GetNextCommandInQueue()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune commande en attente",
"queue": []interface{}{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"next_command": nextCommand,
})
}
// AutoAssignNextCommand assigne automatiquement la prochaine commande en file
// POST /api/v2/admin/protected/queue/auto-assign
func AutoAssignNextCommand(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
}
nextCommand, err := database.GetNextCommandInQueue()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucune commande en attente",
})
return
}
err = database.AutoAssignCommand(nextCommand.CommandID)
if err != nil {
utils.ServerErr(c, "Erreur lors de l'assignation automatique", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée automatiquement",
"command_id": nextCommand.CommandID,
})
}
// ============================================
// GESTION DES LIVREURS - LOCALISATION
// ============================================
// UpdateLivreurLocation met à jour la position GPS du livreur
// POST /api/v1/livreur/location/update
// Body: {"latitude": 48.8566, "longitude": 2.3522}
func UpdateLivreurLocation(c *gin.Context) { func UpdateLivreurLocation(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -171,7 +97,7 @@ func UpdateLivreurLocation(c *gin.Context) {
usernameStr, req.Latitude, req.Longitude) usernameStr, req.Latitude, req.Longitude)
// ✅ Recalculer l'ETA en temps réel si livreur en_route // ✅ Recalculer l'ETA en temps réel si livreur en_route
go refreshETAForActivDelivery(database, usernameStr, req.Latitude, req.Longitude) go refreshETAForActivDelivery(usernameStr, req.Latitude, req.Longitude)
// ✅ 2. Vérifier/Initialiser le statut du livreur // ✅ 2. Vérifier/Initialiser le statut du livreur
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr) statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
@@ -290,14 +216,6 @@ func GetDeliveryPersonLocation(c *gin.Context) {
}) })
} }
// ============================================
// LOCALISATION DU LIVREUR POUR UNE COMMANDE
// ============================================
// GetDeliverymanLocationForCommand récupère la position GPS du livreur assigné à une commande
// GET /api/v2/admin/protected/commands/:id/deliveryman/location (ADMIN)
// GET /api/v1/cabine/commands/:id/deliveryman/location (CABINE)
// Accessible uniquement par les admins et la cabine
func GetDeliverymanLocationForCommand(c *gin.Context) { func GetDeliverymanLocationForCommand(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -389,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
} }
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible) // ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
// ce qui faisait toujours renvoyer etaMinutes=0.
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result() eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
var etaMinutes int = 0 var etaMinutes int = 0
var etaSetAt int64 = 0 var etaSetAt int64 = 0
if etaData != "" { if minutesStr, ok := eta["eta_minutes"]; ok {
var eta map[string]interface{} if minutes, err := strconv.Atoi(minutesStr); err == nil {
json.Unmarshal([]byte(etaData), &eta) etaMinutes = minutes
if minutes, ok := eta["minutes"].(float64); ok {
etaMinutes = int(minutes)
} }
if timestamp, ok := eta["set_at"].(float64); ok { }
etaSetAt = int64(timestamp) if updatedAtStr, ok := eta["updated_at"]; ok {
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
etaSetAt = timestamp
} }
} }
@@ -460,13 +381,6 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
}) })
} }
// ============================================
// GESTION DES LIVREURS - STATUT
// ============================================
// UpdateDeliveryPersonStatus met à jour le statut de disponibilité du livreur
// POST /api/v1/livreur/status
// Body: {"status": "available" | "busy" | "offline"}
func UpdateDeliveryPersonStatus(c *gin.Context) { func UpdateDeliveryPersonStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -490,18 +404,9 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
utils.BindErr(c, err) utils.BindErr(c, err)
return return
} }
// Validation du statut
validStatuses := []string{"available", "busy", "offline"} validStatuses := []string{"available", "busy", "offline"}
isValid := false
for _, s := range validStatuses {
if req.Status == s {
isValid = true
break
}
}
if !isValid { if !slices.Contains(validStatuses, req.Status) {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide", "error": "Statut invalide",
"valid_statuses": validStatuses, "valid_statuses": validStatuses,
@@ -509,7 +414,6 @@ func UpdateDeliveryPersonStatus(c *gin.Context) {
}) })
return return
} }
usernameStr := username.(string) usernameStr := username.(string)
err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0) err := database.SetDeliveryPersonStatus(usernameStr, req.Status, 0)
@@ -604,118 +508,6 @@ func GetMyQueue(c *gin.Context) {
}) })
} }
// GetAvailableDeliveryPersonsRealtime récupère les livreurs disponibles depuis Redis
// GET /api/v2/admin/protected/delivery/available-realtime
func GetAvailableDeliveryPersonsRealtime(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
}
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil {
utils.ServerErr(c, "Erreur lors de la récupération des livreurs", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"livreurs": livreurs,
"count": len(livreurs),
})
}
// ============================================
// GESTION ETA (Estimated Time of Arrival)
// ============================================
// SetCommandETAHandler permet au livreur de définir l'ETA d'une livraison
// POST /api/v1/livreur/deliveries/:id/set-eta
// Body: {"eta_minutes": 25}
func SetCommandETAHandler(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
}
var req struct {
ETAMinutes int `json:"eta_minutes" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
// Validation de l'ETA
if req.ETAMinutes < 1 || req.ETAMinutes > 120 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "L'ETA doit être entre 1 et 120 minutes",
})
return
}
usernameStr := username.(string)
// Vérifier que la commande existe et est assignée au livreur
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
})
return
}
livreurAssign, ok := command["livreur_assign"].(string)
if !ok || livreurAssign != usernameStr {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// Mettre à jour l'ETA dans Redis
err = database.SetCommandETA(commandID, req.ETAMinutes)
if err != nil {
utils.ServerErr(c, "Erreur lors de la mise à jour de l'ETA", err)
return
}
log.Printf("⏱️ ETA défini pour commande %d par %s: %d minutes", commandID, usernameStr, req.ETAMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "ETA mis à jour avec succès",
"command_id": commandID,
"eta_minutes": req.ETAMinutes,
})
}
// ============================================
// PÉNALITÉS - UTILISE PostgreSQL
// ============================================
// ApplyClientPenalty applique une pénalité à un client (Admin seulement)
// POST /api/v2/admin/protected/penalty
// Body: {"username": "john", "points": 50, "reason": "Retard paiement"}
func ApplyClientPenalty(c *gin.Context) { func ApplyClientPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -993,9 +785,6 @@ func ResetClientPenaltiesAdmin(c *gin.Context) {
}) })
} }
// AddClientPointsAdmin ajoute des points à un client dans un pool donné (Admin/Cabine)
// POST /api/v2/admin/protected/client/:username/points/add
// Body: {"pool_key": "pool_0", "points": 10}
func AddClientPointsAdmin(c *gin.Context) { func AddClientPointsAdmin(c *gin.Context) {
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" { if userRole != "admin" && userRole != "cabine" {
@@ -1073,9 +862,6 @@ func AddClientPointsAdmin(c *gin.Context) {
}) })
} }
// SubtractClientPointsAdmin retire des points à un client (plancher à 0)
// POST /api/v2/admin/protected/client/:username/points/subtract
// Body: {"pool_key": "pool_0", "points": 10}
func SubtractClientPointsAdmin(c *gin.Context) { func SubtractClientPointsAdmin(c *gin.Context) {
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" { if userRole != "admin" && userRole != "cabine" {
@@ -1164,49 +950,7 @@ func SubtractClientPointsAdmin(c *gin.Context) {
}) })
} }
// ============================================ func refreshETAForActivDelivery(username string, lat, lon float64) {
// STATISTIQUES TEMPS RÉEL
// ============================================
// GetRealtimeStats récupère les statistiques en temps réel
// GET /api/v2/admin/protected/stats/realtime
func GetRealtimeStats(c *gin.Context) {
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des statistiques",
})
return
}
if len(stats) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune statistique disponible pour le moment",
"stats": map[string]interface{}{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": stats,
})
}
// ============================================
// RECALCUL ETA EN TEMPS RÉEL (appelé à chaque update GPS)
// ============================================
// refreshETAForActivDelivery recalcule l'ETA depuis la position actuelle du livreur.
// Appelé en goroutine à chaque mise à jour GPS (toutes les ~15s).
func refreshETAForActivDelivery(database *db.Database, username string, lat, lon float64) {
// 1. Récupérer le statut actuel du livreur // 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username) statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result() statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
@@ -1277,9 +1021,14 @@ func refreshETAForActivDelivery(database *db.Database, username string, lat, lon
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{ db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID, "command_id": commandID,
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
"eta_minutes": etaMinutes, "eta_minutes": etaMinutes,
"total_eta_minutes": etaMinutes,
"updated_at": now.Unix(), "updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(), "arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
"distance_km": distanceKm, "distance_km": distanceKm,
"with_traffic": err == nil, "with_traffic": err == nil,
}) })
+12 -1
View File
@@ -7,6 +7,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -44,6 +45,16 @@ func GetPublicSettings(c *gin.Context) {
"crypto_only": settings.CryptoOnly, "crypto_only": settings.CryptoOnly,
"nowpayments_currencies": settings.NowPaymentsCurrencies, "nowpayments_currencies": settings.NowPaymentsCurrencies,
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled, "telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
"shop_name": settings.ShopName,
"two_fa_enabled": settings.Telegram2FAEnabled,
"contact_telegram": settings.ContactTelegram,
"client_color_primary": settings.ClientColorPrimary,
"client_color_secondary": settings.ClientColorSecondary,
"client_color_success": settings.ClientColorSuccess,
"client_color_danger": settings.ClientColorDanger,
"client_color_warning": settings.ClientColorWarning,
"client_title_gradient_from": settings.ClientTitleGradientFrom,
"client_title_gradient_to": settings.ClientTitleGradientTo,
}) })
} }
@@ -87,7 +98,7 @@ func UpdateSettings(c *gin.Context) {
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil { if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err) log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
} else { } else {
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", webhookURL) log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
} }
} }
} }
+459
View File
@@ -0,0 +1,459 @@
package handlers
import (
"context"
"fmt"
"gestion/db"
"gestion/models"
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
// sections valides pour le reset
var validStatsSections = map[string]string{
"commandes": "stats_reset_commandes_at",
"revenus": "stats_reset_revenus_at",
"produits": "stats_reset_produits_at",
"heures": "stats_reset_heures_at",
"jours": "stats_reset_jours_at",
"doses": "stats_reset_doses_at",
}
// ResetAdminStats réinitialise une section précise des statistiques.
func ResetAdminStats(c *gin.Context) {
section := c.Param("section")
key, ok := validStatsSections[section]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
return
}
database := c.MustGet("database").(*db.Database)
now := time.Now().UTC().Format(time.RFC3339)
if err := database.ResetAdminStat(key); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
}
func dateFilter(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
// inclus avec des valeurs à zéro.
func GetAdminStatsByMonth(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
monthParam := c.Query("month")
monthStart := time.Now()
if monthParam != "" {
parsed, err := time.Parse("2006-01", monthParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
return
}
monthStart = parsed
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
filters := database.LoadAdminStatsFilters()
var rows []db.DailyMonthStatRow
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
return
}
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
for _, r := range rows {
rowByDay[r.Day.Format("2006-01-02")] = r
}
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
byDay := make([]gin.H, daysInMonth)
var totalOrders int
var totalRevenue float64
var totalQuantity float64
for i := range daysInMonth {
day := monthStart.AddDate(0, 0, i)
key := day.Format("2006-01-02")
r, ok := rowByDay[key]
if !ok {
r = db.DailyMonthStatRow{Day: day}
}
byDay[i] = gin.H{
"day": key,
"label": day.Format("02/01"),
"count": r.Count,
"revenue": r.Revenue,
"quantity": r.Quantity,
}
totalOrders += r.Count
totalRevenue += r.Revenue
totalQuantity += r.Quantity
}
c.JSON(http.StatusOK, gin.H{
"month": monthStart.Format("2006-01"),
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_quantity": totalQuantity,
},
"by_day": byDay,
})
}
func GetAdminDailyDetail(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
dateParam := c.Query("date")
date := time.Now()
if dateParam != "" {
parsed, err := time.Parse("2006-01-02", dateParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
return
}
date = parsed
}
var dailyRows []models.DailyProductRow
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
return
}
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, g := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": g.Category,
"category_color": g.CategoryColor,
"total_quantity": g.TotalQuantity,
"total_revenue": g.TotalRevenue,
"products": g.Products,
}
}
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
c.JSON(http.StatusOK, gin.H{
"date": date.Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
})
}
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
func GetAdminStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
filters := database.LoadAdminStatsFilters()
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
var (
wdRows []models.WeekdayRow
dayRows []models.DayRow
dayRevRows []models.DayRevenueRow
hourRows []models.HourRow
prodRows []models.ProductRow
qtyRows []models.QuantityBreakdownRow
dailyRows []models.DailyProductRow
totalOrders int64
totalRevenue float64
dailyTotalOrders int64
activeDays int64
last30Count int64
)
eg, _ := errgroup.WithContext(context.Background())
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
eg.Go(func() error {
var err error
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
dailyTotalOrders, err = database.DailyOrdersCount()
return err
})
eg.Go(func() error {
var err error
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
return err
})
if err := eg.Wait(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
return
}
// ── Commandes par jour de la semaine ──────────────────────────────────────
byWeekday := make([]gin.H, 7)
wdMap := make(map[int]int, len(wdRows))
for _, r := range wdRows {
wdMap[r.DOW] = r.Count
}
peakCount, peakWeekday := 0, ""
for i := range 7 {
cnt := wdMap[i]
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
if cnt > peakCount {
peakCount = cnt
peakWeekday = weekdayNames[i]
}
}
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
byDay := make([]gin.H, len(dayRows))
for i, r := range dayRows {
byDay[i] = gin.H{
"day": r.Day.Format("2006-01-02"),
"label": r.Day.Format("02/01"),
"count": r.Count,
}
}
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
byDayRevenue := make([]gin.H, len(dayRevRows))
for i, r := range dayRevRows {
byDayRevenue[i] = gin.H{
"day": r.Day.Format("2006-01-02"),
"label": r.Day.Format("02/01"),
"revenue": r.Revenue,
}
}
// ── Commandes & revenus par heure ─────────────────────────────────────────
hourMap := make(map[int]models.HourRow, len(hourRows))
for _, r := range hourRows {
hourMap[r.Hour] = r
}
byHour := make([]gin.H, 24)
for h := range 24 {
r := hourMap[h]
byHour[h] = gin.H{
"hour": h,
"label": fmt.Sprintf("%02dh", h),
"count": r.Count,
"revenue": r.Revenue,
}
}
// ── Top produits ──────────────────────────────────────────────────────────
topProducts := make([]gin.H, len(prodRows))
topProductName := ""
for i, r := range prodRows {
topProducts[i] = gin.H{
"product_id": r.ProductID,
"name": r.Name,
"quantity": r.Quantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
"category": r.Category,
"category_color": r.CategoryColor,
}
if i == 0 {
topProductName = r.Name
}
}
// ── Répartition des doses/quantités ───────────────────────────────────────
type productGroup struct {
ProductID int
Name string
CategoryColor string
TotalOrders int
Quantities []gin.H
}
var groups []productGroup
groupIdx := map[int]int{}
for _, r := range qtyRows {
idx, ok := groupIdx[r.ProductID]
if !ok {
idx = len(groups)
groups = append(groups, productGroup{
ProductID: r.ProductID,
Name: r.ProductName,
CategoryColor: r.CategoryColor,
})
groupIdx[r.ProductID] = idx
}
groups[idx].TotalOrders += r.OrderCount
groups[idx].Quantities = append(groups[idx].Quantities, gin.H{
"quantity": r.Quantity,
"order_count": r.OrderCount,
"total_sold": r.TotalSold,
"revenue": r.Revenue,
})
}
for i := 0; i < len(groups)-1; i++ {
for j := i + 1; j < len(groups); j++ {
if groups[j].TotalOrders > groups[i].TotalOrders {
groups[i], groups[j] = groups[j], groups[i]
}
}
}
if len(groups) > 15 {
groups = groups[:15]
}
byQuantity := make([]gin.H, len(groups))
for i, grp := range groups {
byQuantity[i] = gin.H{
"product_id": grp.ProductID,
"name": grp.Name,
"category_color": grp.CategoryColor,
"total_orders": grp.TotalOrders,
"quantities": grp.Quantities,
}
}
// ── Détail du jour ────────────────────────────────────────────────────────
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, grp := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": grp.Category,
"category_color": grp.CategoryColor,
"total_quantity": grp.TotalQuantity,
"total_revenue": grp.TotalRevenue,
"products": grp.Products,
}
}
// ── Résumé global ─────────────────────────────────────────────────────────
avgPerDay := 0.0
if totalOrders > 0 && activeDays > 0 {
avgPerDay = float64(last30Count) / float64(activeDays)
}
c.JSON(http.StatusOK, gin.H{
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"peak_weekday": peakWeekday,
"top_product": topProductName,
"avg_per_day": avgPerDay,
},
"reset_at_commandes": dateFilter(filters.ResetCommandes),
"reset_at_revenus": dateFilter(filters.ResetRevenus),
"reset_at_produits": dateFilter(filters.ResetProduits),
"reset_at_heures": dateFilter(filters.ResetHeures),
"reset_at_jours": dateFilter(filters.ResetJours),
"reset_at_doses": dateFilter(filters.ResetDoses),
"by_weekday": byWeekday,
"by_day_30": byDay,
"by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
"by_quantity": byQuantity,
"daily_detail": gin.H{
"date": time.Now().Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
},
})
}
+96 -3
View File
@@ -6,6 +6,7 @@ import (
"gestion/services" "gestion/services"
"log" "log"
"net/http" "net/http"
"os"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -91,10 +92,30 @@ func handleLinkAccount(c *gin.Context, token string, chatID int64) {
} }
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID) log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
// Enrollment lbtelegram (best effort — n'empêche pas l'envoi du bouton)
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
if err := services.LBTelegram.EnrollUser(chatID, username, role); err != nil {
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
}
}
// Message de confirmation — bouton vers BOT1 si lbtelegram configuré, sinon texte simple
if services.TelegramBot != nil { if services.TelegramBot != nil {
if services.LBTelegram != nil && services.LBTelegram.Bot1Username != "" {
if err := services.TelegramBot.SendMessageWithButtons(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nPour activer vos notifications, démarrez le bot ci-dessous :",
[][2]string{{"🔔 Activer les notifications", "https://t.me/" + services.LBTelegram.Bot1Username}},
); err != nil {
log.Printf("⚠️ [TELEGRAM] Envoi bouton BOT1 échoué pour %s: %v", username, err)
services.TelegramBot.SendMessage(chatID, services.TelegramBot.SendMessage(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.") "✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
} }
} else {
services.TelegramBot.SendMessage(chatID,
"✅ <b>Compte lié avec succès !</b>\n\nVous recevrez désormais vos notifications ici.")
}
}
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
@@ -118,9 +139,11 @@ func GenerateClientLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -145,9 +168,11 @@ func GenerateLivreurLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -180,9 +205,11 @@ func GenerateAdminLinkToken(c *gin.Context) {
return return
} }
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"token": token, "token": token,
"link_url": "https://t.me/" + services.TelegramBot.BotUsername + "?start=" + token, "link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token, "message": "/start " + token,
"expires_in": 600, "expires_in": 600,
}) })
@@ -242,13 +269,20 @@ func UnlinkClientTelegram(c *gin.Context) {
return return
} }
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientTelegramChatID(username); err != nil { if err := database.DeleteClientTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err) log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return 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) log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
@@ -290,3 +324,62 @@ func UnlinkAdminTelegram(c *gin.Context) {
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username) log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true}) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// ============================================
// LIAISON INTERNE (appelée par LBTelegram)
// ============================================
// POST /api/internal/telegram/link
// Appelée par LBTelegram quand Bot1 reçoit /start TOKEN.
// Valide le token, enregistre le chat_id, déclenche l'enrollment.
func InternalTelegramLink(c *gin.Context) {
secret := c.GetHeader("X-Internal-Secret")
expected := os.Getenv("BACKEND_LINK_SECRET")
if expected == "" || secret != expected {
c.Status(http.StatusUnauthorized)
return
}
var req struct {
ChatID int64 `json:"chat_id" binding:"required"`
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
database := c.MustGet("database").(*db.Database)
username, role, err := db.ValidateAndConsumeLinkToken(req.Token)
if err != nil {
log.Printf("⚠️ [TELEGRAM_LINK_INTERNAL] Token invalide: %v", err)
c.Status(http.StatusUnauthorized)
return
}
var saveErr error
switch role {
case "client":
saveErr = database.SaveClientTelegramChatID(username, req.ChatID)
default:
saveErr = database.SaveUserTelegramChatID(username, req.ChatID)
}
if saveErr != nil {
log.Printf("❌ [TELEGRAM_LINK_INTERNAL] Erreur sauvegarde pour %s: %v", username, saveErr)
c.Status(http.StatusInternalServerError)
return
}
log.Printf("✅ [TELEGRAM_LINK_INTERNAL] Compte %s (%s) lié via Bot1 (chat_id %d)", username, role, req.ChatID)
if services.LBTelegram != nil && services.LBTelegram.IsConfigured() {
if err := services.LBTelegram.EnrollUser(req.ChatID, username, role); err != nil {
log.Printf("⚠️ [LB] enrollment échoué pour %s: %v", username, err)
c.Status(http.StatusInternalServerError)
return
}
}
c.Status(http.StatusOK)
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
) )
// getFloatFromMap récupère un float64 depuis une map avec différents types // getFloatFromMap récupère un float64 depuis une map avec différents types
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) { func getFloatFromMap(m map[string]any, key string) (float64, bool) {
value, exists := m[key] value, exists := m[key]
if !exists || value == nil { if !exists || value == nil {
return 0, false return 0, false
@@ -169,10 +169,6 @@ func GetMyProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)}) 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 // UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
// PUT /api/v2/admin/protected/clients/:id // PUT /api/v2/admin/protected/clients/:id
func UpdateClientByAdmin(c *gin.Context) { func UpdateClientByAdmin(c *gin.Context) {
@@ -202,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) {
return 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 // Récupérer le client actuel
client, err := database.GetClientByID(clientID) client, err := database.GetClientByID(clientID)
if err != nil { if err != nil {
@@ -349,10 +342,6 @@ func UpdateClientByAdmin(c *gin.Context) {
}) })
} }
// ============================================
// MODIFICATION PROFIL USER (PAR ADMIN)
// ============================================
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user // UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
// PUT /api/v2/admin/protected/users/:id // PUT /api/v2/admin/protected/users/:id
func UpdateUserByAdmin(c *gin.Context) { func UpdateUserByAdmin(c *gin.Context) {
@@ -468,10 +457,6 @@ func UpdateUserByAdmin(c *gin.Context) {
}) })
} }
// ============================================
// UTILITAIRES
// ============================================
func sanitizeClient(client *models.Client) gin.H { func sanitizeClient(client *models.Client) gin.H {
return gin.H{ return gin.H{
"id": client.ID, "id": client.ID,
+38 -267
View File
@@ -12,261 +12,6 @@ import (
"github.com/gin-gonic/gin" "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) // 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================ // ============================================
@@ -275,6 +20,7 @@ func CheckDeliveryValidationEligibility(c *gin.Context) {
// POST /api/v1/deliveries/:id/start // POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) { func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" { if !exists || c.GetString("role") != "livreur" {
@@ -359,11 +105,47 @@ func StartDelivery(c *gin.Context) {
} }
} }
} }
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 { if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(float64) destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64) destLon, _ := command["dest_longitude"].(float64)
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
// mais pas encore persisté en DB — cf. goroutine async dans
// handlers/commands.go AssignCommandToDeliveryman).
if destLat == 0 || destLon == 0 {
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); 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, destLon = coords.Lat, coords.Lon
}
}
}
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
// le livreur démarre la livraison avant que la goroutine async
// d'assignation ait fini de géocoder (race condition).
if (destLat == 0 || destLon == 0) && geoService != nil {
if adresse, _ := command["adresse"].(string); adresse != "" {
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
destLat, destLon = location.Latitude, location.Longitude
database.GDB.Exec(
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
destLat, destLon, commandID,
)
}
}
}
if destLat != 0 && destLon != 0 { if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon) etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
} else {
// Fallback 3 : aucune coordonnée exploitable — ETA par
// défaut plutôt que pas d'ETA du tout dans le message.
etaMinutes = 30
} }
} }
if etaMinutes > 0 { if etaMinutes > 0 {
@@ -395,14 +177,3 @@ func StartDelivery(c *gin.Context) {
"status": "en_route", "status": "en_route",
}) })
} }
// ============================================
// HELPERS
// ============================================
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+53 -5
View File
@@ -1,7 +1,3 @@
// ============================================
// main.go - VERSION SIMPLIFIÉE AVEC CLEANUP AUTO
// ============================================
package main package main
import ( import (
@@ -42,6 +38,13 @@ func main() {
geoService := services.NewGeoService(db.Redis, db.RedisCtx) geoService := services.NewGeoService(db.Redis, db.RedisCtx)
log.Println("✅ Service de géolocalisation initialisé") log.Println("✅ Service de géolocalisation initialisé")
lbService := services.NewLBTelegramService()
if lbService.IsConfigured() {
log.Println("✅ Service LBTelegram initialisé")
} else {
log.Println("️ Service LBTelegram désactivé (LBTELEGRAM_URL non défini)")
}
telegramService := services.NewTelegramService() telegramService := services.NewTelegramService()
if telegramService.IsConfigured() { if telegramService.IsConfigured() {
log.Println("✅ Service Telegram initialisé") log.Println("✅ Service Telegram initialisé")
@@ -69,6 +72,49 @@ func main() {
} }
} }
// Ré-enrôler tous les comptes déjà liés dans lbtelegram (au cas où lbtelegram a redémarré)
if lbService.IsConfigured() {
go func() {
accounts, err := database.GetAllLinkedTelegramAccounts()
if err != nil {
log.Printf("⚠️ [LB_SYNC] Erreur lecture comptes liés: %v", err)
return
}
ok, fail := 0, 0
for _, a := range accounts {
if err := lbService.EnrollUser(a.ChatID, a.Username, a.Role); err != nil {
fail++
} else {
ok++
}
}
log.Printf("✅ [LB_SYNC] Re-enrollment terminé: %d OK, %d échecs (total %d comptes)", ok, fail, len(accounts))
}()
}
s3Service, err := services.NewS3Service(
os.Getenv("S3_REGION"),
os.Getenv("S3_BUCKET"),
os.Getenv("S3_ENDPOINT"),
services.S3Credentials{
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
},
)
if err != nil {
log.Fatalf("erreur init S3: %v", err)
}
var storage services.Storage
switch os.Getenv("STORAGE_DRIVER") {
case "s3":
storage = services.NewS3Storage(s3Service)
log.Println("✅ Storage driver: s3 (RustFS)")
default:
storage = services.NewLocalStorage("uploads")
log.Println("✅ Storage driver: local")
}
log.Println("") log.Println("")
log.Println("🧹 Démarrage du nettoyage des commandes invalides...") log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands() removed, err := database.CleanupInvalidQueueCommands()
@@ -130,6 +176,8 @@ func main() {
r.Use(func(c *gin.Context) { r.Use(func(c *gin.Context) {
c.Set("database", database) c.Set("database", database)
c.Set("geoService", geoService) c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
c.Set("storage", storage)
c.Next() c.Next()
}) })
@@ -144,7 +192,7 @@ func main() {
r.Static("/uploads", "./uploads") r.Static("/uploads", "./uploads")
routes.SetupRoutes(r, database, geoService) routes.SetupRoutes(r, database, geoService, s3Service)
if err := r.Run(":8080"); err != nil { if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err) log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
@@ -1,6 +1,7 @@
package middleware package middleware
import ( import (
"fmt"
"gestion/db" "gestion/db"
"log" "log"
"net/http" "net/http"
@@ -60,7 +61,7 @@ func BlockClientIfPenalty(c *gin.Context) {
if amende > 0 { if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende) log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement", "error": fmt.Sprintf("Commande bloquée : vous avez une amende de %.0f€ en attente de paiement. Prenez attache avec Milieu Nantais sur signal pour régulariser votre situation..", amende),
"amende": amende, "amende": amende,
"blocked": true, "blocked": true,
}) })
@@ -21,7 +21,6 @@ func OrderHoursMiddleware(c *gin.Context) {
hour := now.Hour() hour := now.Hour()
min := now.Minute() min := now.Minute()
// Récupérer le planning depuis les settings DB
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings() settings, err := database.GetSettings()
if err != nil { if err != nil {
@@ -54,7 +54,7 @@ var (
// ============================================ // ============================================
// validateClientToken valide un token client // validateClientToken valide un token client
func validateClientToken(tokenString string, database *db.Database) (*ClientClaims, error) { func validateClientToken(tokenString string) (*ClientClaims, error) {
tokenString = strings.TrimSpace(tokenString) tokenString = strings.TrimSpace(tokenString)
if tokenString == "" { if tokenString == "" {
return nil, fmt.Errorf("token vide") return nil, fmt.Errorf("token vide")
@@ -103,7 +103,7 @@ func validateClientToken(tokenString string, database *db.Database) (*ClientClai
} }
// validateAdminToken valide un token admin // validateAdminToken valide un token admin
func validateAdminToken(tokenString string, database *db.Database) (*AdminClaims, error) { func validateAdminToken(tokenString string) (*AdminClaims, error) {
tokenString = strings.TrimSpace(tokenString) tokenString = strings.TrimSpace(tokenString)
if tokenString == "" { if tokenString == "" {
return nil, fmt.Errorf("token vide") return nil, fmt.Errorf("token vide")
@@ -161,7 +161,7 @@ func ClientMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateClientToken(tokenStr, database) claims, err := validateClientToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err) log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
@@ -205,7 +205,7 @@ func AdminMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err) log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
@@ -258,7 +258,7 @@ func CabineMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err) log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
@@ -312,7 +312,7 @@ func LivreurMiddleware(c *gin.Context) {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr, database) claims, err := validateAdminToken(tokenStr)
if err != nil { if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err) log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
@@ -507,94 +507,3 @@ func LoginRateLimitMiddleware(c *gin.Context) {
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10)) c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
c.Next() 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()
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ type RegisterClientRequest struct {
type RegisterAdminRequest struct { type RegisterAdminRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"` Username string `json:"username" binding:"required,min=3,max=50"`
Password string `json:"password" binding:"required,min=8"` Password string `json:"password" binding:"required,min=8"`
Role string `json:"role" binding:"required,oneof=admin cabine livreur"` Role string `json:"role" binding:"required,oneof=cabine livreur"`
} }
type LoginResponse struct { type LoginResponse struct {
+2
View File
@@ -18,9 +18,11 @@ type Client struct {
MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"` MustChangePassword bool `gorm:"column:must_change_password;default:false" json:"must_change_password"`
ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"` ReferralBalance float64 `gorm:"column:referral_balance;default:0" json:"referral_balance"`
PointsExtra map[string]int `gorm:"-" json:"points_extra"` PointsExtra map[string]int `gorm:"-" json:"points_extra"`
PointsRedeemed map[string]int `gorm:"-" json:"points_redeemed"`
Parrain string `gorm:"column:parrain" json:"parrain"` Parrain string `gorm:"column:parrain" json:"parrain"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_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" } func (Client) TableName() string { return "clients" }
+2
View File
@@ -25,6 +25,8 @@ type CommandItem struct {
ProductID int `gorm:"column:product_id" json:"product_id"` ProductID int `gorm:"column:product_id" json:"product_id"`
Quantity float64 `gorm:"column:quantite" json:"quantity"` Quantity float64 `gorm:"column:quantite" json:"quantity"`
Price float64 `gorm:"column:prix" json:"price"` Price float64 `gorm:"column:prix" json:"price"`
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }
+6
View File
@@ -0,0 +1,6 @@
package models
type Contact struct {
ID int `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"not null"`
}
+6 -5
View File
@@ -3,11 +3,12 @@ package models
import "time" import "time"
type Media struct { type Media struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"` ID int `json:"id"`
ProductID int `gorm:"column:product_id" json:"product_id"` ProductID int `json:"product_id"`
Type string `gorm:"column:type" json:"type"` Type string `json:"type"`
URL string `gorm:"column:url" json:"url"` URL string `json:"url"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Key string `json:"-"` // clé interne RustFS, jamais exposée
CreatedAt time.Time `json:"created_at"`
} }
func (Media) TableName() string { return "media" } func (Media) TableName() string { return "media" }
+2
View File
@@ -11,6 +11,8 @@ type Panier struct {
Description string `json:"description"` Description string `json:"description"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Price float64 `json:"price"` Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
RewardPoolKey string `json:"reward_pool_key,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
+9
View File
@@ -9,6 +9,7 @@ type Product struct {
Description string `json:"description" gorm:"column:description"` Description string `json:"description" gorm:"column:description"`
Stock float64 `json:"stock" gorm:"column:stock"` Stock float64 `json:"stock" gorm:"column:stock"`
Unit string `json:"unit" gorm:"column:unit"` Unit string `json:"unit" gorm:"column:unit"`
ComingSoon bool `json:"coming_soon" gorm:"column:coming_soon;default:false"`
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"` Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"` Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
@@ -23,6 +24,14 @@ type ProductPrice struct {
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"` Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
Price float64 `json:"price" gorm:"column:price" binding:"required"` Price float64 `json:"price" gorm:"column:price" binding:"required"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
// Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ
// dont la valeur Go est la valeur zéro (false) s'il porte un tag
// "default", laissant Postgres appliquer sa propre valeur par défaut
// (TRUE) à la place — un prix explicitement désactivé (false) revenait
// donc toujours actif après un Create(). La colonne a déjà son défaut
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
// seulement source du bug.
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
} }
func (ProductPrice) TableName() string { return "product_prices" } func (ProductPrice) TableName() string { return "product_prices" }
-8
View File
@@ -19,11 +19,3 @@ type DeliveryPersonStatus struct {
CurrentCommand int `json:"current_command,omitempty"` CurrentCommand int `json:"current_command,omitempty"`
LastUpdate time.Time `json:"last_update"` 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"`
}
+46 -1
View File
@@ -14,6 +14,32 @@ type PointsTier struct {
Points int `json:"points"` Points int `json:"points"`
} }
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
// ainsi que le type de récompense appliqué pour cette catégorie précise.
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
}
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
type RewardItem struct {
ProductID int `json:"product_id"` // ID du produit ajouté au panier
Quantity float64 `json:"quantity"` // quantité offerte
Price float64 `json:"price"` // valeur indicative affichée au client
}
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie
// dans CategoryConfigs (voir RewardCategoryConfig.Type).
type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
Description string `json:"description"` // description libre affichée au client
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
}
// DaySchedule représente les horaires de livraison pour un jour de la semaine // DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct { type DaySchedule struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -66,6 +92,7 @@ type AppSettings struct {
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
@@ -75,8 +102,26 @@ type AppSettings struct {
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs 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
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
// Palette de couleurs — espace admin
AdminColorPrimary string `json:"admin_color_primary"`
AdminColorSecondary string `json:"admin_color_secondary"`
AdminColorSuccess string `json:"admin_color_success"`
AdminColorDanger string `json:"admin_color_danger"`
AdminColorWarning string `json:"admin_color_warning"`
// Palette de couleurs — app client + site web
ClientColorPrimary string `json:"client_color_primary"`
ClientColorSecondary string `json:"client_color_secondary"`
ClientColorSuccess string `json:"client_color_success"`
ClientColorDanger string `json:"client_color_danger"`
ClientColorWarning string `json:"client_color_warning"`
// Dégradé du titre boutique sur le site web client
ClientTitleGradientFrom string `json:"client_title_gradient_from"`
ClientTitleGradientTo string `json:"client_title_gradient_to"`
} }
+76
View File
@@ -0,0 +1,76 @@
package models
import "time"
type WeekdayRow struct {
DOW int `gorm:"column:dow"`
Count int `gorm:"column:count"`
}
type DayRow struct {
Day time.Time `gorm:"column:day"`
Count int `gorm:"column:count"`
}
type ProductRow struct {
ProductID int `gorm:"column:product_id"`
Name string `gorm:"column:name"`
Quantity float64 `gorm:"column:total_quantity"`
OrderCount int `gorm:"column:order_count"`
Revenue float64 `gorm:"column:revenue"`
Category string `gorm:"column:category"`
CategoryColor string `gorm:"column:category_color"`
}
type HourRow struct {
Hour int `gorm:"column:hour"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type QuantityBreakdownRow struct {
ProductID int `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
Quantity float64 `gorm:"column:quantity"`
OrderCount int `gorm:"column:order_count"`
TotalSold float64 `gorm:"column:total_sold"`
Revenue float64 `gorm:"column:revenue"`
CategoryColor string `gorm:"column:category_color"`
}
type DayRevenueRow struct {
Day time.Time `gorm:"column:day"`
Revenue float64 `gorm:"column:revenue"`
}
type DailyProductRow struct {
ProductID int `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
Category string `gorm:"column:category"`
CategoryColor string `gorm:"column:category_color"`
TotalQuantity float64 `gorm:"column:total_quantity"`
OrderCount int `gorm:"column:order_count"`
Revenue float64 `gorm:"column:revenue"`
}
type DayRowWithResult struct {
Day time.Time `gorm:"column:day"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type WeekRow struct {
WeekNum int `gorm:"column:week_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type MonthRow struct {
MonthNum int `gorm:"column:month_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type TodayRow struct {
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
+51 -7
View File
@@ -1,7 +1,3 @@
// ============================================
// routes/routes.go - VERSION CORRIGÉE COMPLÈTE
// ============================================
package routes package routes
import ( import (
@@ -13,7 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) { func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) {
// ============================================ // ============================================
// 🔐 MIDDLEWARE GLOBAL // 🔐 MIDDLEWARE GLOBAL
@@ -21,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
router.Use(func(c *gin.Context) { router.Use(func(c *gin.Context) {
c.Set("database", database) c.Set("database", database)
c.Set("geoService", geoService) c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
}) })
// ============================================ // ============================================
@@ -34,6 +31,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
{ {
authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient) authGroupV1.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginClient)
authGroupV1.POST("/logout", handlers.LogoutClient) authGroupV1.POST("/logout", handlers.LogoutClient)
authGroupV1.POST("/2fa/verify", middleware.LoginRateLimitMiddleware, handlers.Verify2FAClient)
} }
// Route change-password (auth client requise) // Route change-password (auth client requise)
@@ -82,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// Approbation livraison // Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery) cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal) cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems) cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory) cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
@@ -91,6 +90,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory) cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
// NOTATION LIVREUR
cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating)
cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus)
// ⭐⭐ PÉNALITÉS CLIENT // ⭐⭐ PÉNALITÉS CLIENT
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
@@ -107,10 +110,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cartGroupV1.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil cartGroupV1.GET("/profile", handlers.GetMyProfile) // ✅ Récupérer mon profil
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
// 🔐 2FA CLIENT
cartGroupV1.GET("/two-fa/status", handlers.GetClient2FAStatus)
cartGroupV1.POST("/two-fa/toggle", handlers.ToggleClient2FA)
// 🎁 PARRAINAGE CLIENT // 🎁 PARRAINAGE CLIENT
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance) cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo) cartGroupV1.GET("/parrain", handlers.GetMyParrainInfo)
// 🏆 POINTS & RÉCOMPENSES CLIENT
cartGroupV1.GET("/points/rewards", handlers.GetMyPointsRewards)
cartGroupV1.POST("/points/claim", handlers.ClaimMyReward)
// 💸 STATUT PAIEMENT CRYPTO // 💸 STATUT PAIEMENT CRYPTO
cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus) cartGroupV1.GET("/commands/:id/payment-status", handlers.GetCommandPaymentStatus)
} }
@@ -120,11 +131,26 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook) router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
// ============================================
// 🖼️ PROXY MÉDIAS (RustFS privé via VPN)
// ============================================
router.GET("/media/*key", handlers.ServeMedia)
// ============================================ // ============================================
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header) // 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
// ============================================ // ============================================
router.POST("/webhook/telegram", handlers.TelegramWebhook) router.POST("/webhook/telegram", handlers.TelegramWebhook)
// ============================================
// 🔗 LIAISON INTERNE TELEGRAM (appelée par LBTelegram)
// ============================================
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
// ============================================
// 📋 HEALTH CHECK
// ============================================
router.GET("/health", handlers.Health)
// ============================================ // ============================================
// 📋 PATTERN v2: ADMIN API // 📋 PATTERN v2: ADMIN API
// ============================================ // ============================================
@@ -134,7 +160,6 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
adminAuthGroupV2 := router.Group("/api/v2/admin/auth") adminAuthGroupV2 := router.Group("/api/v2/admin/auth")
{ {
//adminAuthGroupV2.POST("/register", handlers.RegisterAdmin)
adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin) adminAuthGroupV2.POST("/login", middleware.LoginRateLimitMiddleware, handlers.LoginAdmin)
adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin) adminAuthGroupV2.POST("/logout", handlers.LogoutAdmin)
} }
@@ -183,13 +208,24 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct) adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia) adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia) adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
adminGroupV2.POST("/products/:id/stock", handlers.UpdateStock)
// ============================================ // ============================================
// CATÉGORIES - GESTION ADMIN // CATÉGORIES - GESTION ADMIN
// ============================================ // ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory) adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory) adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory) adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
// ============================================ // ============================================
// STATISTIQUES ADMIN
// ============================================
adminGroupV2.GET("/stats", handlers.GetAdminStats)
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
// ============================================
// COMMANDES - GESTION DE BASE // COMMANDES - GESTION DE BASE
// ============================================ // ============================================
adminGroupV2.GET("/orders", handlers.GetAllCommands) adminGroupV2.GET("/orders", handlers.GetAllCommands)
@@ -242,6 +278,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin) adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue) adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks) adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
// Commandes annulées // Commandes annulées
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders) adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
// ============================================ // ============================================
@@ -253,6 +291,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool
adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats) adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
@@ -300,6 +339,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken) cabineGroupV1.POST("/telegram/link-token", handlers.GenerateAdminLinkToken)
cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram) cabineGroupV1.DELETE("/telegram/unlink", handlers.UnlinkAdminTelegram)
cabineGroupV1.GET("/commands", handlers.GetAllCommands)
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems) cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery) cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson) cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
@@ -308,9 +348,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus) cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand) cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen) cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
cabineGroupV1.GET("/delivery-persons/:username", handlers.GetDeliveryPersonDetails)
cabineGroupV1.GET("/all/clients", handlers.GetAllClients)
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine) cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange) cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
// ⭐ NOUVEAU - ANNULATION PAR CABINE
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders) cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités cabineGroupV1.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset pénalités
@@ -358,6 +399,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// QUEUE PERSONNELLE // QUEUE PERSONNELLE
// ============================================ // ============================================
livreurGroupV1.GET("/queue", handlers.GetMyQueue) livreurGroupV1.GET("/queue", handlers.GetMyQueue)
livreurGroupV1.GET("/stats", handlers.GetMyDeliveryStats)
// ============================================ // ============================================
// ALERTES POLICE // ALERTES POLICE
@@ -370,6 +412,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
// NOTIFICATIONS LIVREUR // NOTIFICATIONS LIVREUR
// ============================================ // ============================================
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications) livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead) livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
@@ -0,0 +1,515 @@
package services
import (
"encoding/json"
"fmt"
"gestion/utils"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
)
// ============================================
// TYPES
// ============================================
// AddressSuggestion représente une suggestion de correction
type AddressSuggestion struct {
OriginalAddress string `json:"original_address"`
CorrectedAddress string `json:"corrected_address"`
Coordinates Coordinates `json:"coordinates"`
Confidence float64 `json:"confidence"` // 0.0 à 1.0
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
Source string `json:"source"` // "exact", "fuzzy", "structured"
}
// NominatimSuggestion représente une réponse de l'API Nominatim
type NominatimSuggestion struct {
Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"`
DisplayName string `json:"display_name"`
Importance float64 `json:"importance"`
Type string `json:"type"`
Class string `json:"class"`
Address struct {
HouseNumber string `json:"house_number"`
Road string `json:"road"`
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Postcode string `json:"postcode"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
} `json:"address"`
}
// AddressCorrectionService gère la correction des adresses
type AddressCorrectionService struct {
httpClient *http.Client
geoService *GeoService
}
// NewAddressCorrectionService crée une instance du service de correction
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
return &AddressCorrectionService{
httpClient: &http.Client{Timeout: 10 * time.Second},
geoService: geoService,
}
}
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
acs.geoService.saveToCache(rawAddress, loc)
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
// ── Étape 2 : fuzzy search Nominatim ──
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
return suggestion, nil
}
// ── Étape 3 : décomposition structurée ──
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
return suggestion, nil
}
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
for _, variant := range variants {
suggestions, err := acs.queryNominatim(variant, 5)
if err != nil || len(suggestions) == 0 {
continue
}
best := suggestions[0]
confidence := computeConfidence(address, best.DisplayName, best.Importance)
// On accepte si la confiance est suffisante
if confidence >= 0.40 {
corrected := formatNominatimAddress(best)
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence,
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
Source: "fuzzy",
}, nil
}
}
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("requête vide")
}
params := url.Values{}
params.Set("q", query)
params.Set("format", "json")
params.Set("addressdetails", "1")
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("accept-language", "fr")
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
// Respect du rate-limit Nominatim : 1 req/s
time.Sleep(1100 * time.Millisecond)
resp, err := acs.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var results []NominatimSuggestion
if err := json.Unmarshal(body, &results); err != nil {
return nil, err
}
return results, nil
}
// ============================================
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
// ============================================
// structuredSearch décompose l'adresse et cherche les parties clés
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
s.OriginalAddress = address
s.Source = "structured"
return s, nil
}
}
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
if err == nil && len(suggestions) > 0 {
best := suggestions[0]
return &AddressSuggestion{
OriginalAddress: address,
CorrectedAddress: best.DisplayName,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: 0.30, // faible : seulement ville/CP trouvés
CorrectionApplied: true,
Source: "structured_partial",
}, nil
}
}
return nil, fmt.Errorf("recherche structurée échouée")
}
// ============================================
// VARIANTES D'ADRESSE
// ============================================
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string {
variants := []string{address}
normalized := utils.NormalizeAddress(address)
// Variante sans accents
if normalized != address {
variants = append(variants, normalized)
}
// Variante avec "France" si absent
if !strings.Contains(strings.ToLower(address), "france") {
variants = append(variants, address+", France")
}
// Variante en corrigeant les abréviations courantes françaises
expanded := expandFrenchAbbreviations(address)
if expanded != address {
variants = append(variants, expanded)
variants = append(variants, expanded+", France")
}
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
simplified := simplifyStreetName(address)
if simplified != address {
variants = append(variants, simplified)
}
// Dédoublonnage tout en conservant l'ordre
seen := map[string]bool{}
unique := make([]string, 0, len(variants))
for _, v := range variants {
if !seen[v] {
seen[v] = true
unique = append(unique, v)
}
}
return unique
}
// expandFrenchAbbreviations remplace les abréviations courantes
func expandFrenchAbbreviations(address string) string {
replacements := []struct{ from, to string }{
{"Av.", "Avenue"},
{"Ave.", "Avenue"},
{"Bd.", "Boulevard"},
{"Bld.", "Boulevard"},
{"Blvd.", "Boulevard"},
{"Rte.", "Route"},
{"Rte ", "Route "},
{"Imp.", "Impasse"},
{"Cité", "Cité"},
{"Sq.", "Square"},
{"Pl.", "Place"},
{"Rés.", "Résidence"},
}
result := address
for _, r := range replacements {
result = strings.ReplaceAll(result, r.from, r.to)
}
return result
}
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
func simplifyStreetName(address string) string {
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
words := strings.Fields(address)
if len(words) < 5 {
return address
}
// Retire les articles intégrés dans le nom de rue (heuristique)
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
filtered := make([]string, 0, len(words))
for i, w := range words {
lower := strings.ToLower(w)
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
if i < 2 || !articles[lower] {
filtered = append(filtered, w)
}
}
result := strings.Join(filtered, " ")
if result == address {
return address
}
return result
}
// ============================================
// UTILITAIRES
// ============================================
// addressParts regroupe les composants décomposés d'une adresse
type addressParts struct {
streetNumber string
streetName string
postcode string
city string
}
// parseAddressParts analyse une adresse libre pour en extraire les composants
func parseAddressParts(address string) addressParts {
var parts addressParts
// Extraction du code postal (5 chiffres consécutifs)
words := strings.Fields(address)
remaining := make([]string, 0, len(words))
for _, w := range words {
if isPostcode(w) {
parts.postcode = w
} else {
remaining = append(remaining, w)
}
}
if len(remaining) == 0 {
return parts
}
// Premier mot numérique → numéro de rue
if isNumeric(remaining[0]) {
parts.streetNumber = remaining[0]
remaining = remaining[1:]
}
// Détection de la ville : dernier groupe après le code postal
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
if len(remaining) > 0 {
last := remaining[len(remaining)-1]
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
parts.city = last
remaining = remaining[:len(remaining)-1]
}
}
parts.streetName = strings.Join(remaining, " ")
return parts
}
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := utils.NormalizeAddress(strings.ToLower(original))
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
// Score de similarité sur les mots communs
origWords := strings.Fields(origNorm)
suggWords := strings.Fields(suggNorm)
commonCount := 0
for _, ow := range origWords {
if len(ow) < 3 {
continue // ignorer les petits mots
}
for _, sw := range suggWords {
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
commonCount++
break
}
}
}
var wordScore float64
if len(origWords) > 0 {
wordScore = float64(commonCount) / float64(len(origWords))
}
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
importance := math.Min(nominatimImportance, 1.0)
return wordScore*0.70 + importance*0.30
}
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
func formatNominatimAddress(s NominatimSuggestion) string {
addr := s.Address
var parts []string
if addr.HouseNumber != "" && addr.Road != "" {
parts = append(parts, addr.HouseNumber+" "+addr.Road)
} else if addr.Road != "" {
parts = append(parts, addr.Road)
}
city := addr.City
if city == "" {
city = addr.Town
}
if city == "" {
city = addr.Village
}
if addr.Postcode != "" {
parts = append(parts, addr.Postcode)
}
if city != "" {
parts = append(parts, city)
}
if len(parts) == 0 {
return s.DisplayName
}
return strings.Join(parts, ", ")
}
// isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool {
if len(s) != 5 {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// isNumeric retourne true si la chaîne est entièrement numérique
func isNumeric(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return len(s) > 0
}
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
func levenshteinRatio(a, b string) float64 {
d := levenshtein(a, b)
maxLen := math.Max(float64(len(a)), float64(len(b)))
if maxLen == 0 {
return 1.0
}
return 1.0 - float64(d)/maxLen
}
// levenshtein calcule la distance de Levenshtein entre deux chaînes
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
dp := make([][]int, la+1)
for i := range dp {
dp[i] = make([]int, lb+1)
dp[i][0] = i
}
for j := 0; j <= lb; j++ {
dp[0][j] = j
}
for i := 1; i <= la; i++ {
for j := 1; j <= lb; j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
}
}
return dp[la][lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
+68 -52
View File
@@ -6,10 +6,10 @@ import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"io" "io"
"log"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
@@ -28,10 +28,6 @@ const (
LocationTTL = 1 * time.Hour LocationTTL = 1 * time.Hour
) )
// ============================================
// STRUCTURES
// ============================================
type GeoLocation struct { type GeoLocation struct {
Latitude float64 `json:"lat,string"` Latitude float64 `json:"lat,string"`
Longitude float64 `json:"lon,string"` Longitude float64 `json:"lon,string"`
@@ -54,40 +50,65 @@ type GeoService struct {
redis *redis.Client redis *redis.Client
ctx context.Context ctx context.Context
httpClient *http.Client httpClient *http.Client
correctionService *AddressCorrectionService
} }
// ============================================
// CONSTRUCTEUR
// ============================================
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService { func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
return &GeoService{ gs := &GeoService{
redis: redisClient, redis: redisClient,
ctx: ctx, ctx: ctx,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
} }
// Le correctionService est initialisé après, car il a besoin de gs lui-même
gs.correctionService = NewAddressCorrectionService(gs)
return gs
} }
// ============================================
// GÉOCODAGE - API NOMINATIM
// ============================================
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) { func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
// 1. Vérifier le cache Redis // 1. Cache Redis (adresse originale)
location, err := gs.getFromCache(address) if location, err := gs.getFromCache(address); err == nil {
if err == nil {
return location, nil return location, nil
} }
location, err = gs.fetchFromNominatim(address) // 2. Tentative directe via Nominatim
if err != nil { if location, err := gs.fetchFromNominatim(address); err == nil {
return nil, err gs.saveToCache(address, location)
return location, nil
} }
// 3. Sauvegarder en cache // 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
// Déclenché uniquement si le géocodage direct a échoué.
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
suggestion, err := gs.correctionService.ResolveAddress(address)
if err != nil {
log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err)
return nil, fmt.Errorf("adresse introuvable : '%s'", address)
}
if suggestion.CorrectionApplied {
log.Printf(
"✅ [GEO] Correction appliquée (confiance %.0f%%) : '%s' → '%s'",
suggestion.Confidence*100,
address,
suggestion.CorrectedAddress,
)
}
location := &GeoLocation{
Latitude: suggestion.Coordinates.Latitude,
Longitude: suggestion.Coordinates.Longitude,
DisplayName: suggestion.CorrectedAddress,
}
// Mettre en cache avec l'adresse originale pour les prochains appels
gs.saveToCache(address, location) gs.saveToCache(address, location)
// Mettre en cache aussi avec l'adresse corrigée
if suggestion.CorrectionApplied {
gs.saveToCache(suggestion.CorrectedAddress, location)
}
return location, nil return location, nil
} }
@@ -190,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address) return fmt.Sprintf("geocode:cache:%s", address)
} }
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine) // CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 { func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians // Conversion en radians
@@ -202,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
lat2Rad := toRadians(to.Latitude) lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude) lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad dLon := lon2Rad - lon1Rad
@@ -218,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback) // CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int { func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 { if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes return MinETA
} }
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0 travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%) // Ajouter une marge pour le trafic (environ 20%)
@@ -241,35 +254,38 @@ func CalculateETA(distanceKm float64) int {
return totalMinutes return totalMinutes
} }
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
apiKey := os.Getenv("TOMTOM_API_KEY") if len(tomTomKeys.keys) == 0 {
if apiKey == "" {
// Fallback sur calcul local si pas de clé API
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
return CalculateETA(distance), distance, nil return CalculateETA(distance), distance, nil
} }
// API TomTom Routing: Calculate Route avec trafic
apiURL := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(apiURL)
buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
}
q := url.Values{}
q.Set("key", key)
q.Set("traffic", "true")
q.Set("travelMode", "car")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
}
resp, err := tomTomKeys.Do(client, buildReq)
if err != nil { if err != nil {
// Fallback sur calcul local en cas d'erreur réseau
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
eta := CalculateETA(distance) eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta) fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err)
return eta, distance, nil return eta, distance, nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Fallback sur calcul local en cas d'erreur API
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
eta := CalculateETA(distance) eta := CalculateETA(distance)
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta) fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
@@ -294,18 +310,14 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
} }
summary := routeResponse.Routes[0].Summary summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60 etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
distanceKm := float64(summary.LengthInMeters) / 1000.0 distanceKm := float64(summary.LengthInMeters) / 1000.0
// Appliquer minimum
if etaMinutes < MinETA { if etaMinutes < MinETA {
etaMinutes = MinETA etaMinutes = MinETA
} }
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes) fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil return etaMinutes, distanceKm, nil
} }
@@ -503,13 +515,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
// ============================================ // ============================================
// GetDeliveryHeatmap retourne toutes les positions des livreurs // GetDeliveryHeatmap retourne toutes les positions des livreurs
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) { func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) {
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result() keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
if err != nil { if err != nil {
return nil, err return nil, err
} }
var heatmap []map[string]interface{} var heatmap []map[string]any
for _, key := range keys { for _, key := range keys {
data, err := gs.redis.Get(gs.ctx, key).Result() data, err := gs.redis.Get(gs.ctx, key).Result()
@@ -517,7 +529,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
continue continue
} }
var location map[string]interface{} var location map[string]any
json.Unmarshal([]byte(data), &location) json.Unmarshal([]byte(data), &location)
username := key[len("delivery:location:"):] username := key[len("delivery:location:"):]
@@ -528,3 +540,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
return heatmap, nil return heatmap, nil
} }
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
return gs.correctionService
}
+89
View File
@@ -0,0 +1,89 @@
package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
var LBTelegram *LBTelegramService
type LBTelegramService struct {
gatewayURL string
Bot1Username string
Bot2Username string
client *http.Client
}
func NewLBTelegramService() *LBTelegramService {
url := os.Getenv("LBTELEGRAM_URL")
if url == "" {
url = "http://lbtelegram:8081"
}
svc := &LBTelegramService{
gatewayURL: url,
Bot1Username: os.Getenv("LBTELEGRAM_BOT1_USERNAME"),
Bot2Username: os.Getenv("LBTELEGRAM_BOT2_USERNAME"),
client: &http.Client{Timeout: 10 * time.Second},
}
LBTelegram = svc
return svc
}
func (s *LBTelegramService) IsConfigured() bool {
return os.Getenv("LBTELEGRAM_URL") != ""
}
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]any{
"user_id": chatID,
"username": username,
"role": role,
"chat_id": chatID,
}
body, _ := json.Marshal(payload)
resp, err := s.client.Post(s.gatewayURL+"/enrollment/begin", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("enrollment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("enrollment HTTP %d: %s", resp.StatusCode, string(b))
}
log.Printf("✅ [LB] Enrollment OK pour %s (%s)", username, role)
return nil
}
// SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]any{
"user_id": userID,
"message": message,
}
body, _ := json.Marshal(payload)
resp, err := s.client.Post(s.gatewayURL+"/notify", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("notify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("notify HTTP %d: %s", resp.StatusCode, string(b))
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
type S3Service struct {
client *s3.Client
bucketName string
}
type S3Credentials struct {
S3KeyId string
S3AccessKey string
}
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
var cfg aws.Config
var err error
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
cfg, err = config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
),
)
} else {
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
}
if err != nil {
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
o.UsePathStyle = true
}
})
return &S3Service{
client: client,
bucketName: bucketName,
}, nil
}
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
ext := filepath.Ext(fileHeader.Filename)
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
return s.UploadFileWithName(fileHeader, folder, fileName)
}
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := buf.ReadFrom(file); err != nil {
return "", fmt.Errorf("erreur lecture fichier: %w", err)
}
key = fmt.Sprintf("%s/%s", folder, fileName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
Body: bytes.NewReader(buf.Bytes()),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("erreur upload RustFS: %w", err)
}
return key, nil
}
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
}
contentType := "application/octet-stream"
if out.ContentType != nil {
contentType = *out.ContentType
}
return out.Body, contentType, nil
}
// DeleteFile supprime un fichier à partir de sa clé S3.
func (s *S3Service) DeleteFile(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("erreur suppression RustFS: %w", err)
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package services
import (
"fmt"
"gestion/utils"
"io"
"mime/multipart"
"os"
"path/filepath"
)
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
// pour que tous les points d'upload/suppression respectent le même driver.
type Storage interface {
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
// chaque implémentation ignore celui qui ne la concerne pas.
Delete(url string, key string) error
}
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
type LocalStorage struct {
baseDir string
}
func NewLocalStorage(baseDir string) *LocalStorage {
return &LocalStorage{baseDir: baseDir}
}
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
destFolder := filepath.Join(s.baseDir, folder)
if err := os.MkdirAll(destFolder, 0750); err != nil {
return "", "", fmt.Errorf("erreur création dossier: %w", err)
}
filePath := filepath.Join(destFolder, fileName)
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return "", "", fmt.Errorf("chemin invalide: %w", err)
}
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer src.Close()
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return "", "", fmt.Errorf("erreur création fichier: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
os.Remove(safeFilePath)
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
}
return "/" + filepath.ToSlash(safeFilePath), "", nil
}
func (s *LocalStorage) Delete(url string, key string) error {
filePath := ""
if len(url) > 0 && url[0] == '/' {
filePath = url[1:]
} else {
filePath = url
}
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return fmt.Errorf("chemin invalide: %w", err)
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("erreur suppression: %w", err)
}
return nil
}
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
type S3Storage struct {
s3 *S3Service
}
func NewS3Storage(s3 *S3Service) *S3Storage {
return &S3Storage{s3: s3}
}
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
if err != nil {
return "", "", err
}
return "/media/" + key, key, nil
}
func (s *S3Storage) Delete(url string, key string) error {
if key == "" {
return fmt.Errorf("clé S3 manquante pour suppression")
}
return s.s3.DeleteFile(key)
}
+48 -3
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
) )
// TelegramBot est l'instance globale accessible depuis le package db
var TelegramBot *TelegramService var TelegramBot *TelegramService
type TelegramService struct { type TelegramService struct {
@@ -65,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return fmt.Errorf("telegram non configuré") return fmt.Errorf("telegram non configuré")
} }
payload := map[string]interface{}{ payload := map[string]any{
"chat_id": chatID, "chat_id": chatID,
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
@@ -96,13 +95,59 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return nil return nil
} }
// SendMessageWithButtons envoie un message HTML avec des boutons inline (URL buttons).
// buttons est une liste de paires [texte, url].
func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, buttons [][2]string) error {
if !t.IsConfigured() {
return fmt.Errorf("telegram non configuré")
}
row := make([]map[string]string, 0, len(buttons))
for _, b := range buttons {
row = append(row, map[string]string{"text": b[0], "url": b[1]})
}
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"reply_markup": map[string]any{
"inline_keyboard": [][]map[string]string{row},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.botToken)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("création requête: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("envoi: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API status %d", resp.StatusCode)
}
return nil
}
// SetWebhook enregistre l'URL webhook auprès de Telegram // SetWebhook enregistre l'URL webhook auprès de Telegram
func (t *TelegramService) SetWebhook(webhookURL string) error { func (t *TelegramService) SetWebhook(webhookURL string) error {
if !t.IsConfigured() { if !t.IsConfigured() {
return fmt.Errorf("telegram non configuré") return fmt.Errorf("telegram non configuré")
} }
payload := map[string]interface{}{ payload := map[string]any{
"url": webhookURL, "url": webhookURL,
"allowed_updates": []string{"message"}, "allowed_updates": []string{"message"},
} }
+17 -15
View File
@@ -11,25 +11,30 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "net/url"
"time" "time"
) )
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) { func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
apiKey := os.Getenv("TOMTOM_API_KEY") client := &http.Client{Timeout: 10 * time.Second}
if apiKey == "" {
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée") buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
}
q := url.Values{}
q.Set("key", key)
q.Set("traffic", "true")
q.Set("travelMode", "car")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
} }
url := fmt.Sprintf( resp, err := tomTomKeys.Do(client, buildReq)
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err) return 0, 0, fmt.Errorf("erreur TomTom: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -53,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
} }
summary := routeResponse.Routes[0].Summary summary := routeResponse.Routes[0].Summary
// Calculer ETA en minutes (arrondi supérieur)
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60 etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
distanceKm = float64(summary.LengthInMeters) / 1000.0 distanceKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes) log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
return etaMinutes, distanceKm, nil return etaMinutes, distanceKm, nil
} }
+92
View File
@@ -0,0 +1,92 @@
package services
import (
"fmt"
"io"
"log"
"net/http"
"os"
"sync/atomic"
)
type tomTomKeyManager struct {
keys []string
current atomic.Int32
}
var tomTomKeys = initTomTomKeyManager()
func initTomTomKeyManager() *tomTomKeyManager {
m := &tomTomKeyManager{}
seen := map[string]bool{}
candidates := []string{
os.Getenv("TOMTOM_API_KEY"),
os.Getenv("TOMTOM_API_KEY_1"),
os.Getenv("TOMTOM_API_KEY_2"),
os.Getenv("TOMTOM_API_KEY_3"),
}
for _, k := range candidates {
if k != "" && !seen[k] {
seen[k] = true
m.keys = append(m.keys, k)
}
}
log.Printf("🔑 [TOMTOM] %d clé(s) API configurée(s)", len(m.keys))
return m
}
// currentKey retourne la clé active et son index.
func (m *tomTomKeyManager) currentKey() (string, int) {
n := len(m.keys)
if n == 0 {
return "", -1
}
idx := int(m.current.Load()) % n
return m.keys[idx], idx
}
// rotate passe à la clé suivante.
func (m *tomTomKeyManager) rotate(fromIdx int) {
n := len(m.keys)
if n <= 1 {
return
}
next := int32((fromIdx + 1) % n)
m.current.CompareAndSwap(int32(fromIdx), next)
log.Printf("🔄 [TOMTOM] Rotation clé %d → clé %d (quota atteint)", fromIdx+1, next+1)
}
// Do exécute la requête en rotant automatiquement sur 403/429.
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
n := len(m.keys)
if n == 0 {
return nil, fmt.Errorf("aucune clé TomTom configurée (TOMTOM_API_KEY / TOMTOM_API_KEY_1..3)")
}
_, startIdx := m.currentKey()
for attempt := range n {
idx := (startIdx + attempt) % n
key := m.keys[idx]
req, err := buildReq(key)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
m.rotate(idx)
continue
}
return resp, nil
}
return nil, fmt.Errorf("toutes les clés TomTom ont atteint leur quota (%d clé(s) testée(s))", n)
}
@@ -0,0 +1,126 @@
package tests
import (
"gestion/db"
"gestion/services"
"testing"
"time"
)
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
// purs de services/adresses_correction_test.go (normalisation, décomposition,
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
//
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
// (Nominatim tolère nativement la casse, les accents et certaines
// abréviations sans point) et ce qui nécessite réellement la logique de
// correction (variantes, décomposition structurée, repli ville+code postal).
//
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
// envers le service public.
const (
nantesLatMin, nantesLatMax = 47.15, 47.28
nantesLonMin, nantesLonMax = -1.65, -1.45
)
func isWithinNantes(lat, lon float64) bool {
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
}
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
cases := []struct {
name string
input string
minConfidence float64
maxConfidence float64
}{
{
// Nominatim tolère nativement la casse et l'absence d'accent :
// résolution directe (étape 1 de ResolveAddress), confiance max.
name: "tout minuscule sans accent",
input: "12 rue crebillon 44000 nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Abréviation sans point ("Pl" au lieu de "Place") — également
// tolérée nativement par Nominatim, résolution directe.
name: "abréviation sans point",
input: "3 Pl Royale 44000 Nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
// Gambeta) : vérifié que la recherche Nominatim directe ET
// toutes les variantes générées par l'algorithme (accents,
// abréviations, décomposition structurée essais 1 et 2)
// échouent — seul le repli ville+code postal (essai 3,
// confiance fixe 0.30) aboutit. Documente une vraie limite :
// l'algorithme ne corrige pas les fautes de frappe arbitraires
// dans un nom de rue, il retombe sur "quelque part dans la
// bonne ville".
name: "faute de frappe non corrigible sur le nom de rue",
input: "15 Rue Gambeta 44000 Nantes",
minConfidence: 0.25,
maxConfidence: 0.35,
},
}
for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
if i > 0 {
time.Sleep(1200 * time.Millisecond)
}
suggestion, err := correction.ResolveAddress(c.input)
if err != nil {
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
}
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
}
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
}
if suggestion.CorrectedAddress == "" {
t.Errorf("adresse corrigée vide pour %q", c.input)
}
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
})
}
}
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
if err == nil {
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
}
}
@@ -0,0 +1,243 @@
package tests
import (
"gestion/models"
"testing"
)
// db_address.go gère une table de correspondances gérées par l'admin
// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse
// saisie par le client correspond à une entrée connue comme invalide, et si
// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer
// une nouvelle confirmation côté client (voir ValidateBasket).
func cleanupAddressCorrections(t *testing.T, ids ...int64) {
t.Helper()
t.Cleanup(func() {
for _, id := range ids {
testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id)
}
})
}
func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) {
cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
cmd := &models.Command{DeliveryAddress: invalid}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// addCorrectionForFallback enregistre une correction et retourne une fonction
// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de
// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test).
func addCorrectionForFallback(t *testing.T, invalid, correct string) {
t.Helper()
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
}
// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress
// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée
// par l'admin avec un texte exact donné doit continuer à s'appliquer même si
// le client tape une variante mineure (casse, accents, espaces), plutôt que
// d'échouer silencieusement et laisser passer une adresse non livrable.
func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante de casse)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) {
invalid := testUserPrefix + "10 Rue du Général Buat Nantes"
correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
// Saisie sans accent par le client, alors que la correction enregistrée
// par l'admin en contient un.
cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante d'accent)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) {
invalid := testUserPrefix + "5 Cours des 50 Otages Nantes"
correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (espaces multiples)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) {
invalid := testUserPrefix + "8 Rue de Verdun Nantes"
correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// Le fallback compare une égalité normalisée stricte, pas une similarité
// floue : une adresse réellement différente (même partiellement proche) ne
// doit jamais être substituée par erreur.
func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
// Avec plusieurs corrections enregistrées, le fallback doit retrouver la
// bonne entrée (pas la première venue) même via une variante normalisée.
func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) {
invalidA := testUserPrefix + "1 Rue A Nantes"
correctA := testUserPrefix + "1 Rue A, 44000 Nantes"
invalidB := testUserPrefix + "2 Rue B Nantes"
correctB := testUserPrefix + "2 Rue B, 44000 Nantes"
addCorrectionForFallback(t, invalidA, correctA)
addCorrectionForFallback(t, invalidB, correctB)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"}
if err := testDB.CheckAddress(cmd); err == nil {
t.Fatal("attendu une erreur signalant la correction B")
}
if cmd.DeliveryAddress != correctB {
t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB)
}
}
func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) {
invalid := testUserPrefix + "adresse invalide test"
correct := testUserPrefix + "adresse correcte test"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
found := false
for _, a := range all {
if a.InvalidAddress == invalid && a.CorrectAddress == correct {
found = true
break
}
}
if !found {
t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress")
}
}
// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre
// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en
// base (adresse_correction_invalid_address_key), donc deux corrections ne
// peuvent jamais partager la même adresse invalide — le risque réel est
// seulement qu'un DELETE mal ciblé touche une correspondance différente.
func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) {
invalidA := testUserPrefix + "adresse A"
correctA := testUserPrefix + "correction A"
invalidB := testUserPrefix + "adresse B"
correctB := testUserPrefix + "correction B"
if err := testDB.AddAddress(correctA, invalidA); err != nil {
t.Fatalf("AddAddress A: %v", err)
}
if err := testDB.AddAddress(correctB, invalidB); err != nil {
t.Fatalf("AddAddress B: %v", err)
}
var idA, idB int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA)
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB)
cleanupAddressCorrections(t, idA, idB)
if err := testDB.DeleteAddress(invalidA, correctA); err != nil {
t.Fatalf("DeleteAddress: %v", err)
}
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
var stillHasA, stillHasB bool
for _, a := range all {
if a.InvalidAddress == invalidA && a.CorrectAddress == correctA {
stillHasA = true
}
if a.InvalidAddress == invalidB && a.CorrectAddress == correctB {
stillHasB = true
}
}
if stillHasA {
t.Error("la correspondance ciblée (A) doit être supprimée")
}
if !stillHasB {
t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée")
}
}
+273
View File
@@ -0,0 +1,273 @@
package tests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if username != "" {
c.Set("username", username)
}
c.Set("role", role)
if alertID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}}
}
return c, rec
}
func createTestAlert(t *testing.T, username, message string) int {
t.Helper()
alert, err := testDB.CreateAlert(username, message)
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID)
})
return alert.ID
}
// ── AlertPolice ──────────────────────────────────────────────────────────────
func TestAlertPolice_LivreurCreatesAlert(t *testing.T) {
livreur := testUserPrefix + "alert_create_livreur"
body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"})
c, rec := alertContext(livreur, "livreur", body, 0)
handlers.AlertPolice(c)
t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) })
if rec.Code != http.StatusCreated {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
AlertID int `json:"alert_id"`
User string `json:"user"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.User != livreur {
t.Errorf("user: got=%q want=%q", resp.User, livreur)
}
alert, err := testDB.GetAlertPolicy(resp.AlertID)
if err != nil {
t.Fatalf("GetAlertPolicy: %v", err)
}
if alert.Message != "Contrôle en cours" || alert.Status != "true" {
t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status)
}
}
func TestAlertPolice_NonLivreurForbidden(t *testing.T) {
for _, role := range []string{"client", "admin", "cabine"} {
t.Run(role, func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"message": "test"})
c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0)
handlers.AlertPolice(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code)
}
})
}
}
// ── GetAlert ─────────────────────────────────────────────────────────────────
func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) {
livreur := testUserPrefix + "alert_view_own"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_owner"
intruder := testUserPrefix + "alert_view_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── EndAlert ─────────────────────────────────────────────────────────────────
func TestEndAlert_OwnerCanEnd(t *testing.T) {
livreur := testUserPrefix + "alert_end_owner"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "false" {
t.Errorf("statut après EndAlert: got=%q want=false", alert.Status)
}
}
func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_end_owner2"
intruder := testUserPrefix + "alert_end_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "true" {
t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status)
}
}
// ── DeleteAlert ──────────────────────────────────────────────────────────────
//
// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme
// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire.
func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner_ok"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(owner, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée")
}
}
func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner"
intruder := testUserPrefix + "alert_delete_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err != nil {
t.Error("l'alerte ne doit pas être supprimée par un intrus")
}
}
func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) {
owner := testUserPrefix + "alert_delete_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée par l'admin")
}
}
func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) {
owner := testUserPrefix + "alert_delete_forbidden_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code)
}
}
// ── Listing ──────────────────────────────────────────────────────────────────
func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) {
mine := testUserPrefix + "alert_mine"
other := testUserPrefix + "alert_other"
createTestAlert(t, mine, "à moi 1")
createTestAlert(t, mine, "à moi 2")
createTestAlert(t, other, "pas à moi")
c, rec := alertContext(mine, "livreur", nil, 0)
handlers.GetMyAlerts(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Count int `json:"count"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Count != 2 {
t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count)
}
}
func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) {
livreur := testUserPrefix + "alert_active_filter"
activeID := createTestAlert(t, livreur, "active")
endedID := createTestAlert(t, livreur, "terminée")
if err := testDB.EndAlert(endedID); err != nil {
t.Fatalf("EndAlert (setup): %v", err)
}
alerts, err := testDB.GetActiveAlerts()
if err != nil {
t.Fatalf("GetActiveAlerts: %v", err)
}
var foundActive, foundEnded bool
for _, a := range alerts {
if a.ID == activeID {
foundActive = true
}
if a.ID == endedID {
foundEnded = true
}
}
if !foundActive {
t.Error("l'alerte active doit apparaître dans GetActiveAlerts")
}
if foundEnded {
t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts")
}
}

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