Author SHA1 Message Date
Xor290 f84dc153ec chore: build
Backend - Build & Lint / build (push) Successful in 32m46s
Frontend Admin - EAS Build / build (push) Successful in 1h34m15s
2026-09-13 13:19:08 +02:00
Xor290 b6189ac787 chore: build
Backend - Build & Lint / build (push) Successful in 40m56s
Frontend Admin - EAS Build / build (push) Successful in 1h28m48s
Frontend Client - EAS Build / build (push) Successful in 1h23m23s
Frontend Web - Build & Lint / build (push) Successful in 15m50s
2026-09-12 17:08:19 +02:00
Xor290 9f5f709507 chore: build
Frontend Admin - EAS Build / build (push) Successful in 1h28m12s
Frontend Client - EAS Build / build (push) Successful in 1h24m10s
Frontend Web - Build & Lint / build (push) Failing after 6m2s
2026-09-12 13:54:25 +02:00
Xor290 4fbed720e8 chore: build
Backend - Build & Lint / build (push) Canceled after 30m0s
Frontend Admin - EAS Build / build (push) Successful in 1h30m40s
Frontend Client - EAS Build / build (push) Successful in 1h25m40s
Frontend Web - Build & Lint / build (push) Failing after 5m56s
2026-09-10 19:44:23 +02:00
Xor290 a7fe98830b fix(ci): adapte les pipelines Docker/OTA à la structure de la branche pre-prod
- backend-build.yml / frontend-web-build.yml: le chemin des Dockerfile était
  codé en dur sur docker-prod/..., inexistant sur pre-prod (seul
  docker-pre-prod/ existe sur cette branche) — rendu conditionnel selon la
  branche cible, comme le tag Docker l'est déjà.
- frontend-admin-build-local.yml: l'étape de publication OTA (Xavia) injectait
  une EXPO_PUBLIC_API_URL différente de celle utilisée pour builder l'APK
  (ota_api_url pointait vers https://5.181.0.112.nip.io au lieu de
  PREPROD_API_URL) — toute mise à jour OTA publiée sur le canal admin
  pre-prod embarquait donc une mauvaise URL d'API, provoquant un "Network
  Error" au login après réception de l'update, alors que l'APK fraîchement
  buildé fonctionnait. Alignée sur mobile qui n'avait pas ce bug.
2026-09-10 19:34:21 +02:00
Xor290 f904a37964 chore: build
Backend - Build & Lint / build (push) Canceled after 1m5s
Frontend Client - EAS Build / build (push) Canceled after 23s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-10 19:26:05 +02:00
Xor290 c69dc70680 fix: corrige le domaine GATEWAY_URL (uber-demo.club) dans .env.lbtelegram 2026-09-10 19:26:00 +02:00
Xor290 f3dfb7b2ae chore: build
Frontend Admin - EAS Build / build (push) Canceled after 4m44s
Frontend Client - EAS Build / build (push) Canceled after 2s
2026-09-10 19:09:52 +02:00
Xor290 a7add1d2f7 chore: build
Backend - Build & Lint / build (push) Canceled after 23m40s
2026-09-10 19:00:07 +02:00
Xor290 88b5a48956 chore: readme 2026-09-09 21:33:28 +02:00
Xor290 4474b84e49 chore: build
Frontend Web - Build & Lint / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Canceled after 21m50s
Frontend Admin - EAS Build / build (push) Successful in 1h50m27s
Frontend Client - EAS Build / build (push) Successful in 1h50m44s
2026-09-08 17:55:18 +02:00
Xor290 75b80b8f59 chore: build 2026-09-08 17:53:21 +02:00
Xor290 82f9a2fae9 chore: build
Backend - Build & Lint / build (push) Canceled after 11m16s
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Frontend Web - Build & Lint / build (push) Canceled after 0s
2026-09-08 17:44:28 +02:00
Xor290 06abfee274 chore: build
Backend - Build & Lint / build (push) Failing after 29m22s
2026-08-20 21:46:16 +02:00
Xor290 12cc14eb2b chore: build
Frontend Client - EAS Build / build (push) Failing after 1h41m31s
Frontend Web - Build & Lint / build (push) Successful in 15m1s
2026-08-20 19:36:22 +02:00
Xor290 5a6861ae04 chore: update ci 2026-08-20 16:29:59 +02:00
Xor290 4499d76b9a chore: reward fix
Backend - Build & Lint / build (push) Failing after 29m2s
2026-08-20 15:55:56 +02:00
Xor290 901d013830 chore: build
Backend - Build & Lint / build (push) Failing after 28m7s
Frontend Admin - EAS Build / build (push) Failing after 1h39m13s
Frontend Client - EAS Build / build (push) Failing after 1h38m12s
Frontend Web - Build & Lint / build (push) Failing after 10m56s
2026-08-19 17:49:04 +02:00
Xor290 1623a9eafd chore: fix bug
Backend - Build & Lint / build (push) Failing after 32m32s
2026-08-04 21:09:58 +02:00
Xor290 e5a17443cf chore: update 2026-08-04 18:45:02 +02:00
Xor290 39216fc137 fix: fixup adresse correction
Backend - Build & Lint / build (push) Failing after 33m6s
Frontend Client - EAS Build / build (push) Successful in 2h3m29s
Frontend Web - Build & Lint / build (push) Failing after 10m16s
2026-08-04 18:08:50 +02:00
Nuxgrid c35f99b410 chore: fix 2026-08-01 22:22:17 +02:00
Nuxgrid 020ba7c1e4 chore: build
Backend - Build & Lint / build (push) Failing after 30m33s
2026-08-01 21:51:32 +02:00
Nuxgrid 0d9b4adc3a chore: fix
Backend - Build & Lint / build (push) Failing after 41m24s
Frontend Admin - EAS Build / build (push) Successful in 2h16m49s
2026-07-30 15:30:39 +02:00
Nuxgrid 075dc45ca1 chore: add button in settings for restore color
Frontend Admin - EAS Build / build (push) Successful in 2h19m19s
2026-07-30 10:10:23 +02:00
Nuxgrid 45e8e75be9 chore: gitignore 2026-07-22 21:13:14 +02:00
Nuxgrid 9c6deb59e6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h4m30s
2026-07-18 22:36:36 +02:00
Nuxgrid 48340317d8 chore: add history connection deliveryman
Frontend Admin - EAS Build / build (push) Failing after 2h5m32s
2026-07-18 18:02:04 +02:00
Nuxgrid dfa432862a chore: fix recompense categorie
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 2h1m17s
Frontend Web - Build & Lint / build (push) Failing after 16m7s
2026-07-18 16:41:42 +02:00
Nuxgrid d6f5f2b1f2 chore: build 2026-07-15 20:58:47 +02:00
Nuxgrid 74bf9cf14c chore: build
Backend - Build & Lint / build (push) Failing after 16m27s
Frontend Admin - EAS Build / build (push) Failing after 2h15m57s
2026-07-15 20:55:46 +02:00
Nuxgrid e5333395b9 chore: build
Frontend Admin - EAS Build / build (push) Failing after 2h23m56s
Frontend Client - EAS Build / build (push) Failing after 2h13m7s
2026-07-15 14:20:08 +02:00
Nuxgrid 545e033a5f chore: update pipeline 2026-07-15 14:19:37 +02:00
Nuxgrid 05d0d879fe chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-15 14:18:24 +02:00
Nuxgrid 734493363f chore: build
Backend - Build & Lint / build (push) Failing after 29m5s
Frontend Client - EAS Build / build (push) Failing after 2h25m33s
2026-07-14 18:34:58 +02:00
Nuxgrid 8c3fa39d86 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-13 20:29:33 +02:00
Nuxgrid b52977af03 chore: add new ignore file 2026-07-13 13:17:06 +02:00
Nuxgrid d8d34932e5 chore: fix
Backend - Build & Lint / build (push) Failing after 28m47s
2026-07-13 12:20:13 +02:00
Nuxgrid 4de42cff57 chore: build
Backend - Build & Lint / build (push) Failing after 28m23s
2026-07-12 15:20:53 +02:00
Nuxgrid 2919bca86d chore: build
Frontend Admin - EAS Build / build (push) Successful in 2h0m43s
2026-07-12 14:54:02 +02:00
Nuxgrid becadc0bb2 chore: build for maj
Frontend Client - EAS Build / build (push) Has been cancelled
2026-07-12 11:23:56 +02:00
Nuxgrid 4409ccf574 chore: update ci 2026-07-12 11:23:37 +02:00
Nuxgrid ae7c16c9ee chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m42s
2026-07-11 16:17:21 +02:00
Nuxgrid 06e35964b1 chore: build
Backend - Build & Lint / build (push) Failing after 25m51s
2026-07-11 14:56:56 +02:00
Nuxgrid f3141f8f71 chore: build
Backend - Build & Lint / build (push) Failing after 24m43s
2026-07-11 11:55:09 +02:00
Nuxgrid 54fb87a464 chore: fix go cache 2026-07-11 11:54:25 +02:00
Nuxgrid f674b6ef80 chore: fix bug
Backend - Build & Lint / build (push) Has been cancelled
2026-07-11 11:45:13 +02:00
Nuxgrid f531ddcda8 chore: build
Frontend Client - EAS Build / build (push) Failing after 2h9m57s
Frontend Admin - EAS Build / build (push) Failing after 2h24m0s
2026-07-08 22:24:39 +02:00
Nuxgrid 12a7facc45 chore: build
Backend - Build & Lint / build (push) Failing after 16m17s
2026-07-08 21:54:00 +02:00
Xor290 5cd8ada828 chore: build
Backend - Build & Lint / build (push) Failing after 31m35s
2026-07-06 21:22:30 +02:00
Xor290 75a241dadb chore: build
Backend - Build & Lint / build (push) Failing after 41m47s
2026-07-06 19:57:18 +02:00
Xor290 fcf1e737d6 fix basket
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 22:16:02 +02:00
Xor290 cc3a283fba chore: build
Backend - Build & Lint / build (push) Failing after 20m59s
2026-07-01 21:53:04 +02:00
Xor290 c8d1c75ee9 chore: build
Backend - Build & Lint / build (push) Failing after 17m31s
2026-07-01 21:11:36 +02:00
Xor290 cfb5b79025 chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 20:56:41 +02:00
Xor290 9e6473ed3f fix: sql and redis request
Backend - Build & Lint / build (push) Failing after 19m3s
2026-07-01 20:10:00 +02:00
Xor290 2311631825 fix: approved by client
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:57:12 +02:00
Xor290 211df2e8d6 fix
Backend - Build & Lint / build (push) Has been cancelled
2026-07-01 19:39:33 +02:00
Xor290 a95ee51f6c chore: build
Backend - Build & Lint / build (push) Failing after 17m9s
2026-06-30 22:06:55 +02:00
Xor290andClaude Sonnet 4.6 07936f0bf6 fix: handle NULL parrain column in GetClientParrain
Backend - Build & Lint / build (push) Failing after 24m40s
sql.NullString permet de scanner une valeur NULL depuis la colonne
parrain sans erreur de conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:39:25 +02:00
Xor290 2b1e8ec2fc chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 21:28:57 +02:00
Xor290 6cd3fc674c chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-30 20:02:27 +02:00
Xor290 8d131a1ade chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-30 19:24:36 +02:00
Xor290 781212f706 chore: fix latest build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-29 18:56:00 +02:00
Xor290 9fcc77307f chore: add new file 2026-06-28 16:01:48 +02:00
Xor290 1721f55060 chore: build
Backend - Build & Lint / build (push) Failing after 17m35s
2026-06-28 16:01:23 +02:00
Xor290 b8aab5643f chore: build
Backend - Build & Lint / build (push) Failing after 16m14s
2026-06-28 13:52:47 +02:00
Xor290 3be323fcfe chore: build
Frontend Admin - EAS Build / build (push) Failing after 59m42s
2026-06-28 12:44:17 +02:00
Xor290 75161a1ae0 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-28 12:28:46 +02:00
Xor290 1d7dbe1eaa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-28 12:24:32 +02:00
Xor290 d9ad4cb2cf chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 23:54:42 +02:00
Xor290 69711796a3 chore: build
Frontend Web - Build & Lint / build (push) Failing after 12m18s
2026-06-27 19:56:45 +02:00
Xor290 7e6629167f chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:50:49 +02:00
Xor290 a8a3cdeed9 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:44:51 +02:00
Xor290 784642ffa6 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:40:01 +02:00
Xor290 79a51a03aa chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:35:54 +02:00
Xor290 83329cef41 chore: build
Frontend Web - Build & Lint / build (push) Failing after 2m0s
2026-06-27 19:32:28 +02:00
Xor290 5f734d38e4 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:23:31 +02:00
Xor290 9641d7cc45 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-27 19:15:00 +02:00
Xor290 2fc6711eaa chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m37s
2026-06-27 14:50:26 +02:00
Xor290 304263a95e chore: build
Frontend Admin - EAS Build / build (push) Failing after 13m6s
2026-06-27 14:30:58 +02:00
Xor290 c3e61f12fa chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-27 14:23:26 +02:00
Xor290 066443e228 chore: build
Frontend Client - EAS Build / build (push) Failing after 57m7s
Frontend Web - Build & Lint / build (push) Failing after 15m48s
2026-06-26 19:36:56 +02:00
Xor290 dae547cfa9 fix
Backend - Build & Lint / build (push) Failing after 16m48s
2026-06-25 19:46:46 +02:00
Xor290 b670bc3108 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Failing after 56m32s
Frontend Web - Build & Lint / build (push) Failing after 14m33s
2026-06-23 22:02:04 +02:00
Xor290 bafed46be6 chore: build
Frontend Admin - EAS Build / build (push) Failing after 58m6s
2026-06-23 20:45:58 +02:00
Xor290andClaude Sonnet 4.6 a9c53c3b62 feat: redesign sidebar with iOS frosted glass style
Frontend Web - Build & Lint / build (push) Has been cancelled
- Background: primary color + dark/light bg mixed with blur(50px) saturate(180%)
- Menu items split in two groups (nav / account) as iOS-style cards
- Inset separators, chevrons on each item, no border/outline on buttons
- Active item: primary color icon background with glow
- Telegram/Logout unified as menu-group in footer
- Light mode: white frosted background with dark text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:07:23 +02:00
Xor290andClaude Sonnet 4.6 ff967f0b7b fix: rename coming-soon classes to avoid CSS conflict with ProductCard
Frontend Web - Build & Lint / build (push) Has been cancelled
ProductCard.css also defines .coming-soon-overlay with rotate(-15deg)
causing the diagonal display. Renamed category-level classes to
category-cs-overlay/text/desc to isolate them from product card styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:00:15 +02:00
Xor290andClaude Sonnet 4.6 70988be353 feat: add reset buttons for heures/jours/doses stats, fix home button color
Frontend Admin - EAS Build / build (push) Failing after 59m23s
Frontend Client - EAS Build / build (push) Failing after 57m18s
- StatsScreen: SectionResetBtn for heures d'affluence, jours d'affluence, doses populaires
- api_admin.ts: extend StatSection and AdminStats with heures/jours/doses fields
- HomeScreen (mobile client): use colors.accent for Se connecter button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:54:41 +02:00
Xor290 68bd0d57d6 chore: build front web
Frontend Web - Build & Lint / build (push) Failing after 2m6s
2026-06-22 21:41:22 +02:00
Xor290 4c74de017f chore: build backend
Backend - Build & Lint / build (push) Has been cancelled
2026-06-22 21:28:22 +02:00
Xor290 6111f88dc0 chore: build frontweb
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-22 21:27:55 +02:00
Xor290 f5de187c82 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h1m17s
2026-06-21 19:37:44 +02:00
Xor290 b73d190574 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 19:33:14 +02:00
Xor290 d28dbf9fb0 chore: build
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-21 18:48:30 +02:00
Xor290 1494f5e669 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h0m49s
2026-06-21 16:48:13 +02:00
Xor290 a1a8e6c3f3 chore: build
Frontend Admin - EAS Build / build (push) Failing after 56m59s
2026-06-21 15:07:04 +02:00
Xor290 531602512b chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 15:00:53 +02:00
Xor290 5568ebdf31 chore: build
Backend - Build & Lint / build (push) Has been cancelled
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:48:38 +02:00
Xor290 880d5ac234 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 14:27:54 +02:00
Xor290 3c265fbecf chore: build
Frontend Web - Build & Lint / build (push) Failing after 16m8s
2026-06-21 13:52:53 +02:00
Xor290 a2a796ffd8 chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h4m59s
2026-06-21 12:24:03 +02:00
Xor290 cee859539e chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-21 12:19:54 +02:00
Xor290 13d978c76e chore: build
Backend - Build & Lint / build (push) Has been cancelled
2026-06-21 12:13:28 +02:00
Xor290 403e765a51 chore: build
Frontend Admin - EAS Build / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
2026-06-20 21:17:59 +02:00
Xor290 768de08f87 chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 21:07:37 +02:00
Xor290 202bab0e03 chore: build frontend
Frontend Web - Build & Lint / build (push) Failing after 13m58s
2026-06-20 20:30:06 +02:00
Xor290 2c8979c34f chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:27:57 +02:00
Xor290 4bec5dcd6d chore: build frontend
Frontend Web - Build & Lint / build (push) Has been cancelled
2026-06-20 20:26:24 +02:00
Xor290 74f3d4815e chore: build backend frontend web
Frontend Web - Build & Lint / build (push) Has been cancelled
Backend - Build & Lint / build (push) Has been cancelled
2026-06-20 20:14:18 +02:00
Xor290 26f4e75314 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 59m14s
2026-06-20 16:19:49 +02:00
Xor290 26c0c169a3 chore: build admin
Frontend Admin - EAS Build / build (push) Failing after 12m33s
2026-06-20 15:56:27 +02:00
Xor290 9ec83e3987 chore: build
Frontend Web - Build & Lint / build (push) Has been cancelled
Frontend Client - EAS Build / build (push) Has been cancelled
Frontend Admin - EAS Build / build (push) Has been cancelled
2026-06-20 15:45:11 +02:00
Xor290 1d0e396776 chore: build 2026-06-19 19:42:37 +02:00
Xor290 f576db7cdd chore: build
Frontend Web - Build & Lint / build (push) Failing after 14m59s
2026-06-18 22:45:05 +02:00
Xor290 450f930603 chore: build
Backend - Build & Lint / build (push) Failing after 4m1s
2026-06-18 22:44:12 +02:00
Xor290 5a77bacb04 chore: update ci 2026-06-18 22:43:42 +02:00
Xor290 af9092fa34 chore: build
Backend - Build & Lint / Build (push) Has been cancelled
Backend - Build & Lint / Docker Build & Push (push) Has been cancelled
Backend - Build & Lint / SSH Deploy (push) Has been cancelled
Backend - Build & Lint / Static Analysis (golangci-lint + gosec) (push) Has been cancelled
2026-06-18 22:32:57 +02:00
Xor290 9e0231a88e chore: build 2026-06-18 12:56:41 +02:00
Xor290 b12358ae02 chore: build 2026-06-17 18:09:33 +02:00
Xor290 88b6a03a9c chore: build 2026-06-17 09:47:52 +02:00
Xor290 98722b0579 chore: fix 2026-06-16 22:48:16 +02:00
Xor290 4f9e73a305 chore: fix 2026-06-16 21:24:37 +02:00
Xor290 ec7e59550b chore: build 2026-06-16 21:17:54 +02:00
Xor290 440792a066 chore: fix 2026-06-15 22:12:06 +02:00
Xor290 12ae7282a6 chore: build 2026-06-15 21:28:22 +02:00
Xor290 4ff4bef415 chore: build 2026-06-15 21:26:29 +02:00
Xor290 5e37f4263c chore: build 2026-06-15 21:16:37 +02:00
Xor290 9cf462abdd chore: build 2026-06-15 21:14:25 +02:00
Xor290 d4c06a6d3d chore: build 2026-06-15 21:03:12 +02:00
Xor290 c3c059eab6 chore: fix 2026-06-15 20:03:36 +02:00
Xor290 f72313299d chore: build 2026-06-15 19:24:33 +02:00
Xor290 1f33a89f80 chore: delete docker 2026-06-14 17:38:15 +02:00
Xor290 9404b1e77b chore: update 2026-06-14 17:36:41 +02:00
Xor290 1a1d62a1c1 chore:build 2026-06-14 16:34:34 +02:00
Xor290 b9a532abd5 chore: build 2026-06-14 15:46:24 +02:00
Xor290 2cd7f38908 chore: build 2026-06-14 15:17:44 +02:00
Xor290 8a503b544e chore: build 2026-06-14 15:17:15 +02:00
Xor290 be7e3b3bd8 chore: build 2026-06-14 00:20:56 +02:00
Xor290 83075e30d6 chore: build 2026-06-13 21:55:21 +02:00
Xor290 c0ff6dcd1d chore: build 2026-06-13 19:45:19 +02:00
Xor290 6f8de729ba chore: build 2026-06-13 19:23:24 +02:00
Xor290 093a7e0c42 chore: build 2026-06-13 19:18:12 +02:00
Xor290 59d4f89a14 chore: build 2026-06-13 15:22:51 +02:00
Xor290 dd861fd7be chore: build 2026-06-13 14:49:22 +02:00
Xor290 e30d394c0a chore: build 2026-06-13 14:35:54 +02:00
Xor290 263c21b3b4 chore: build 2026-06-13 14:27:42 +02:00
Xor290 446b15d29c chore: build 2026-06-13 14:23:23 +02:00
Xor290 6f699bea6a chore: build 2026-06-13 12:44:16 +02:00
Xor290 13c09c10c3 chore: build 2026-06-13 12:40:44 +02:00
Xor290 4a67b0cd82 chore: update 2026-06-12 18:14:31 +02:00
Xor290 ab6092ae82 chore: build 2026-06-12 18:02:24 +02:00
Xor290 ce45314c78 chore: update 2026-06-12 17:51:17 +02:00
Xor290 d18052e749 chore: update 2026-06-12 16:11:41 +02:00
Xor290 370b1a5742 chore: build 2026-06-12 15:44:36 +02:00
Xor290 0a8548033d chore: build 2026-06-12 15:33:52 +02:00
Xor290 4792536900 chore: build 2026-06-12 15:24:34 +02:00
Xor290 cc15901d93 chore: build 2026-06-12 15:18:33 +02:00
Xor290 2c3b5cd7d2 chore: build 2026-06-12 14:47:04 +02:00
Xor290 49d7c6506b chore: build 2026-06-12 14:40:50 +02:00
Xor290 48966dcb86 chore: build 2026-06-12 13:44:28 +02:00
Xor290 7f09400e79 chore: build 2026-06-12 13:33:07 +02:00
Xor290 20da2a560f chore: build 2026-06-12 11:13:47 +02:00
Xor290 cc278ecef5 chore: build 2026-06-12 10:47:30 +02:00
Xor290 1d96d558b9 chore: build 2026-06-12 10:06:18 +02:00
Xor290 63dabc3f8e chore: build 2026-06-12 10:02:37 +02:00
Xor290 888932454c chore: build 2026-06-12 09:52:12 +02:00
Xor290 2ca28e730b chore: build 2026-06-12 09:43:29 +02:00
Xor290 13df186854 chore: build 2026-06-12 09:31:15 +02:00
Xor290 a57be7d7fe chore: build 2026-06-12 09:17:27 +02:00
Xor290 bdb76b4d3f chore: update 2026-06-12 09:16:45 +02:00
Xor290 06ab24c69b chore: build 2026-06-12 09:15:20 +02:00
Xor290 88030da8be chore: build 2026-06-11 23:23:29 +02:00
Xor290 92e1e50a5d chore: update ci 2026-06-11 22:05:55 +02:00
Xor290 41c1d4c24a chore: build 2026-06-11 21:51:40 +02:00
Xor290 061fb152cb chore: build 2026-06-11 20:27:03 +02:00
Xor290 7f0b3ff29b chore: build 2026-06-11 20:22:52 +02:00
Xor290 b810248d74 chore: build 2026-06-11 20:13:43 +02:00
Xor290 aada31a7da chore: build 2026-06-11 19:50:39 +02:00
Xor290 58cbc569c4 chore: fix 2026-06-11 19:38:32 +02:00
Xor290 b5196c5f22 chore: build 2026-06-11 19:04:04 +02:00
Xor290 a1d5664d6b chore: build 2026-06-11 18:41:03 +02:00
Xor290 39dfbb940c chore: build 2026-06-11 14:59:05 +02:00
Xor290 3e02aaa9b2 chore: build 2026-06-11 14:50:59 +02:00
Xor290 9b50dca96c chore: build 2026-06-11 12:16:02 +02:00
Xor290 624f8a0e72 chore: build 2026-06-11 11:16:03 +02:00
Xor290 fbb680ccb2 chore: build 2026-06-11 11:10:51 +02:00
Xor290 38e9bff7ee chore: update docker 2026-06-10 13:56:07 +02:00
Xor290 0317fb45df chore: build mobile 2026-06-09 21:01:10 +02:00
Xor290 8a853074b7 chore: update 2026-06-09 20:59:14 +02:00
Xor290 e7b83f406b chore: build 2026-06-09 20:10:24 +02:00
Xor290 b379508831 chore: build 2026-06-09 20:04:39 +02:00
Xor290 15a454768d chore: update 2026-06-09 20:04:01 +02:00
Xor290 7262d44f5a chore: build 2026-06-09 19:57:56 +02:00
Xor290 72c8003acf chore: build 2026-06-09 19:57:19 +02:00
Xor290 1bee48d81a chore: update pipeline 2026-06-09 19:56:44 +02:00
Xor290 56a94c8439 chore: build 2026-05-23 16:45:02 +02:00
Xor290 8df5bd66d3 chore: finish features comming soon 2026-05-19 20:11:54 +02:00
Xor290 929dea7249 chore: update 2026-05-19 18:54:36 +02:00
Xor290 c71b596400 chore: update 2026-05-19 18:42:51 +02:00
Xor290 d773a9c49f fix: ts 2026-05-19 17:43:30 +02:00
Xor290 93120f6bfe feat: update multiple tomtom keys and switch tomtom key and add comming soon button 2026-05-19 17:35:39 +02:00
Xor290 48bdba750e chore: update README 2026-05-18 21:41:12 +02:00
Xor290 f9b55d4ae4 chore: update README 2026-05-18 21:21:52 +02:00
Xor290 f87a9e1b97 chore: update route 2026-05-18 17:54:20 +02:00
Xor290 3dfc15cec1 fix(security): create just cabine and livreur with api but create admin is just in bdd 2026-05-18 17:35:35 +02:00
Xor290 a8e22e63bc fix(security): modify law for create users and clients 2026-05-18 12:30:36 +02:00
Xor290 e69403043d chore: add volume for logs modsec 2026-05-18 10:04:36 +02:00
Xor290 cf686ffa51 chore: add button for deliverymen 2026-05-17 16:09:15 +02:00
Xor290 a8696b12fa chore: add quantity stat 2026-05-17 14:54:59 +02:00
Xor290 94fa9de6a7 fix(security): SSRF log injection and folder permissions 2026-05-17 14:13:38 +02:00
Xor290 72a813cfc1 chore: build 2026-05-17 14:01:40 +02:00
Xor290 86c01789b4 chore: add gosec in CI 2026-05-17 14:00:38 +02:00
Xor290 ac1d3d8d9f fix: stock manage 2026-05-17 13:55:56 +02:00
Xor290 16792094e3 chore: build 2026-05-16 12:16:29 +02:00
Xor290 f00b3a981d update 2026-05-15 22:56:48 +02:00
Xor290 0350074242 update README 2026-05-15 22:28:21 +02:00
Xor290 afbe310dd7 update 2026-05-15 21:44:46 +02:00
Xor290 cefc7e9f32 chore: update message 2026-05-15 20:11:13 +02:00
Xor290 e09bbb05b9 feat: chrono and add penality 2026-05-15 19:13:13 +02:00
Xor290 7443d06029 chore: fix 2026-05-15 18:10:23 +02:00
Xor290 f951ed9187 chore: fix 2026-05-15 17:09:15 +02:00
Xor290 58b9a0827f fix 2026-05-15 16:54:09 +02:00
Xor290 04671637d2 update 2026-05-15 16:45:56 +02:00
Xor290 921c861311 chore: fix ci 2026-05-15 16:44:59 +02:00
Xor290 1342e71a3f chore: fix 2026-05-15 13:48:19 +02:00
Xor290 32f46fcfd4 chore: add new button 2026-05-15 13:28:30 +02:00
Xor290 928a5f5f57 chore: ota 2026-05-15 12:15:42 +02:00
Xor290 3eba3e3a15 fix 2026-05-15 11:15:30 +02:00
Xor290 775b9200f5 feat: group order items by product with category totals 2026-05-14 21:10:54 +02:00
Xor290 4ad80fa57d chore: fix error in suivilivraison.tsx 2026-05-14 20:52:42 +02:00
Xor290 dce417c209 chore: refacto 2026-05-14 19:33:39 +02:00
Xor290 e0c354d76c feat: active & deactivate the price for product 2026-05-14 16:20:59 +02:00
Xor290 0fe8a06bfa fix: manage stock 2026-05-14 15:31:38 +02:00
Xor290 426f4b42ee chore: refacto db 2026-05-13 19:03:33 +02:00
Xor290 0039dda8a0 refacto 2026-05-13 16:43:20 +02:00
Xor290 65d1367f42 fix: manage stock 2026-05-13 11:30:22 +02:00
Xor290 fb63855d9b feat: desactive and active price for product 2026-05-13 10:31:52 +02:00
Xor290 9a2f0d91ad fix: manage quantity && update geoloca 2026-05-12 19:35:02 +02:00
Xor290 1319a4219a fix: CI 2026-05-10 21:10:47 +02:00
Xor290 f41938e563 chore: build 2026-05-10 19:48:02 +02:00
Xor290 6f52f984e8 fix: CI 2026-05-10 19:47:10 +02:00
Xor290 7251280598 chore: build 2026-05-10 18:20:23 +02:00
Xor290 1b32355117 fix: CI 2026-05-10 18:13:37 +02:00
Xor290 73c5965a31 chore: build 2026-05-10 16:58:33 +02:00
Xor290 2be5b6e2bf chore: build 2026-05-10 16:37:59 +02:00
Xor290 71ccbd553e fix: CI ota job 2026-05-10 16:37:13 +02:00
Xor290 2bec488fd9 fix: CI ota job 2026-05-10 16:33:53 +02:00
Xor290 8792b1691e chore: script cache update CI client 2026-05-10 15:54:33 +02:00
Xor290 3c16ca015d feat: add stats page for admin 2026-05-10 15:39:26 +02:00
Xor290 a081fd3b4d chore: add toggle 2FA validation by clients 2026-05-10 15:24:04 +02:00
Xor290 1c04ce3095 chore: build 2026-05-10 14:26:04 +02:00
Xor290 f678c948dd chore: fix project id expo 2026-05-09 23:40:09 +02:00
Xor290 57fb1b1ecd chore: build app in local 2026-05-09 22:55:22 +02:00
Xor290 7af2103164 chore: build 2026-05-09 16:50:23 +02:00
Xor290 78c498159d chore: build 2026-05-09 15:47:27 +02:00
Xor290 0a4953334a chore: ci 2026-05-09 15:44:09 +02:00
Xor290 80115c057b chore: build 2026-05-09 15:35:53 +02:00
Xor290 6958b30fa0 chore: add branch pre-prod in ci backend 2026-05-09 15:35:05 +02:00
Xor290 63c6086625 chore: build 2026-05-09 15:34:15 +02:00
Xor290 97712f240e fix: ts 2026-05-09 15:30:48 +02:00
Xor290 810d7bcabd fix: ts 2026-05-09 15:28:54 +02:00
Xor290andGitHub e47cc2bf57 Merge pull request #2 from UBARSTUPAR/feat/V3-uber
Feat/v3 uber
2026-05-09 15:18:32 +02:00
Xor290 1672dc1e20 feat: add 2FA and change title 2026-05-09 15:17:19 +02:00
Xor290 771f514af3 chore: add clamav 2026-05-08 19:02:03 +02:00
Xor290 ec056b49bc fix: icone 2026-05-03 22:59:45 +02:00
Xor290 77f1d9db2c fix: icone 2026-05-03 22:16:37 +02:00
Xor290 4a6265f3a9 chore 2026-05-03 21:41:25 +02:00
Xor290 e89724bd28 fix: ci 2026-05-03 21:38:46 +02:00
Xor290 3abc825aee chore: build 2026-05-03 21:34:59 +02:00
Xor290 4e4605f400 chore: build 2026-05-03 21:22:20 +02:00
Xor290 48c8e0ea66 chore: fix CI 2026-05-03 21:18:57 +02:00
Xor290 cf28fad1a9 chore: build 2026-05-03 21:17:41 +02:00
Xor290 60192f3748 chore: fix 2026-05-03 20:52:23 +02:00
Xor290 afbe18b896 chore: CI 2026-05-03 20:48:20 +02:00
Xor290andGitHub 68c3a25c26 Merge pull request #1 from UBARSTUPAR/pre-prod
Pre prod
2026-05-03 20:37:04 +02:00
Xor290 38c73a15a1 chore: fix 2026-05-03 20:32:38 +02:00
Xor290 30e387aeed Merge branch 'main' into pre-prod 2026-05-03 20:30:02 +02:00
Xor290 187ce1b187 chore: update pipeline 2026-05-03 20:24:18 +02:00
Xor290 b1fa0ad608 chore: fix 2026-05-03 19:46:12 +02:00
Xor290 37ae4117b4 chore: fix 2026-05-03 19:44:35 +02:00
Xor290 f8473b9a54 chore: fix 2026-05-03 19:38:29 +02:00
Xor290 4075ae2568 chore: fix icones 2026-05-03 18:25:33 +02:00
Xor290 7559ec6c5d feat: detect telegram notif is enabled or no 2026-05-03 17:14:20 +02:00
Xor290 812cf00cef feat: detect telegram notif is enabled or no 2026-05-03 17:12:38 +02:00
Xor290 387986d6f5 feat: notif telegram 2026-05-03 15:11:13 +02:00
Xor290 74d499e328 fix: url 2026-05-03 14:47:09 +02:00
Xor290 b323cf298c chore: fix url 2026-05-03 13:44:25 +02:00
Xor290 78cdf074e2 chore: fix ts 2026-05-03 13:26:20 +02:00
Xor290 7dc2686f6c feat: add CSV export for approved orders && add non-delivery reason modal for livreurs 2026-05-03 13:23:20 +02:00
Xor290 a273484659 chore: delete dependency 2026-05-03 12:42:54 +02:00
Xor290 e6d9b09016 chore: update backend 2026-05-03 12:31:45 +02:00
Xor290 e4ddfaf8af chore: fix ts 2026-05-03 12:16:28 +02:00
Xor290 a4f3497a78 chore: build 2026-05-03 12:14:29 +02:00
Xor290 3c6f35c225 chore: update CI mobile 2026-05-02 18:14:24 +02:00
Xor290 cd6c13d3a5 chore: add url pre-prod 2026-05-02 18:13:00 +02:00
Xor290 90a23b065b chore: add CD 2026-05-02 18:10:33 +02:00
Xor290 a9d8fb5297 ci: add CD pipeline with Docker Hub push on pre-prod 2026-05-02 17:58:09 +02:00
447 changed files with 125062 additions and 7340 deletions
@@ -0,0 +1,158 @@
---
name: comprehension-metier
description: Charge le modèle métier complet de la plateforme de gestion de commandes/livraison (rôles, cycle de vie des commandes, stock, catalogue, points/récompenses, parrainage, pénalités, paiements, GPS/assignation, alertes, paramètres configurables). À invoquer avant toute analyse, debug ou modification qui touche à la logique métier — pas seulement au code — pour raisonner avec les vraies règles du business plutôt qu'avec des hypothèses.
---
# Compréhension métier — Plateforme de gestion de commandes/livraison
Référence condensée mais complète du domaine, construite à partir du `README.md`, des modèles Go (`models/`) et du code des handlers/DB. Objectif : éviter de raisonner uniquement "à partir du code" sans connaître les règles métier réelles, ce qui est la source la plus fréquente de bugs silencieux dans ce projet (stock, remboursements, idempotence, paramètres codés en dur au lieu de suivre `AppSettings`).
## Contexte général
Plateforme de commande + livraison ("Milieu-Nantais", contact Telegram `MLN44LA`) avec catalogue produit par catégories (ex. pools de points nommés "Cannabis", "Accessoires" dans les settings par défaut), paiement cash ou crypto, livreurs géolocalisés avec assignation automatique, et un livreur dispose d'un bouton d'alerte police en cas de contrôle/danger pendant une livraison. Cette nature du produit (aucune auto-inscription client, alerte police, paiement crypto natif, pénalités dissuasives sur annulation tardive) doit rester présente à l'esprit : les règles de sécurité et de discrétion opérationnelle (VPN, filtrage des données sensibles pour les livreurs, pas de traces inutiles) sont volontaires, pas accidentelles.
## Rôles et permissions
| Rôle | Description | Peut faire |
|------|-------------|------------|
| **client** | Utilisateur final | Panier, checkout, suivi commande, approuver/annuler, parrainage, points/récompenses, profil, 2FA |
| **admin** | Gestion complète | Tout : produits, clients, commandes, livreurs, cabine, pénalités, paramètres globaux, reset stats |
| **livreur** | Livreur assigné | Voir ses livraisons (données client filtrées), changer statut, position GPS, queue, alerte police, notifications |
| **cabine** | Cuisine/préparation | Voir items commande, préparer/emballer, assigner livreur, confirmer réception, pénalités client, alertes |
Règles clés (dont certaines issues du changelog sécurité v5.4.0) :
- **Aucune auto-inscription** — les comptes clients sont créés **uniquement par un admin** (`POST /api/v2/admin/protected/clients`). Un nouvel endpoint d'inscription libre serait une régression de sécurité majeure.
- **Création de comptes admin entièrement bloquée côté application** — un compte `admin` ne peut être créé qu'en base de données directement, jamais via l'API, quel que soit le rôle appelant (y compris un autre admin).
- **`cabine` n'a plus aucun droit de création d'utilisateurs ou de clients** (retiré côté backend en v5.4.0) — seul `admin` crée des comptes `livreur` ou `cabine`.
- JWT séparés par famille de rôle : secret client (`USER_JWT_SECRET`, expiration 5h) ≠ secret admin/livreur/cabine (`ADMIN_JWT_SECRET`, expiration 10h/2h selon contexte).
- Chaque action livreur doit vérifier que la commande lui est **assignée** (`livreur_assign == usernameStr`), pas seulement le rôle.
- **Filtrage des données sensibles** : les livreurs ne reçoivent jamais le téléphone du client dans `GET /livreur/deliveries` — uniquement nom/prénom. Tout nouvel endpoint livreur exposant des données client doit respecter ce filtrage.
## Cycle de vie d'une commande
```
pending → assigned → en_route → arrived → livre → approved
↓ ↓ ↓ ↓
cancelled (depuis presque tous les états — jamais depuis approved, jamais deux fois de suite)
```
- `pending` : créée au checkout, en attente d'assignation livreur (auto-assign GPS au checkout, ou worker CRON toutes les 1 minute, ou assignation manuelle admin/cabine).
- `assigned` : livreur choisi, pas encore parti. Le livreur peut aussi être réassigné manuellement (admin/cabine).
- `en_route` : livreur en chemin (`start` puis mise à jour de statut). ETA calculée (TomTom, fallback Haversine) et stockée dans Redis (`command:eta:{id}`), utilisée pour les notifications Telegram avec ETA.
- `arrived` : livreur à destination — déclenché par le livreur (GPS), ou par admin/cabine via bouton "Le livreur est là" (`notify-client`). Notifie le client (Telegram). **Timer 5 minutes** démarre côté app livreur (`frontend-admin`, `DashboardScreen.tsx`, `ABSENT_TIMEOUT_SECS = 300`) → si le client ne descend pas, bouton **"Client absent"** apparaît.
- `livre` : livraison confirmée. Deux voies : validation GPS livreur (distance ≤ 100m de la destination, coordonnées obligatoires) via `PUT /livreur/deliveries/:id/status`, ou override admin/cabine (`force-validate`/statut direct). En attente d'approbation client pour finaliser.
- `approved` : finalisée. Déclenché par le client (`POST /commands/:id/approve` avec note + commentaire livreur), ou admin/cabine (`confirm-reception`/statut direct en override). Points de fidélité attribués **à ce moment précis**, jamais avant (`CalculateAndAddPointsForCommandTx`, même transaction que le passage en `approved`). **Terminal** — plus aucune modification de stock ou de statut après.
- `cancelled` : peut survenir depuis quasiment tous les états précédents. Jamais depuis `approved`, jamais une seconde fois depuis `cancelled` (idempotence obligatoire).
- `pending_payment` : statut intermédiaire spécifique au paiement crypto (voir section Paiements) — pas dans le cycle "normal", bascule vers `pending` (paiement confirmé) ou `cancelled` (paiement échoué/expiré).
**Trois chemins de code différents pour l'annulation** : `CancelCommandAtomic` (client), `UpdateDeliveryStatus`/branche `cancelled` (livreur — inclut le flux "client absent"), `UpdateCommandStatusAdmin` (admin/cabine). Toute règle métier touchant l'annulation (remboursement stock, pénalité, notification) doit être répercutée dans les **trois**, plus `CancelCryptoCommand` pour le cas crypto.
**Correction d'adresse** : si une adresse ne peut pas être géocodée ou est jugée invalide, un flux de proposition existe (`adresse_correction` table, `invalid_address``correct_address`) — le client peut répondre à une proposition (`POST /commands/:id/address/respond`), l'admin peut modifier l'adresse directement (`PUT /orders/:id/address`).
## Produits, catalogue et tarification
- Un produit (`products`) a un `stock` en **float** (pas un entier — permet des unités fractionnaires/dosages), une `unit`, une ou plusieurs catégories, un flag `coming_soon` (produit visible mais pas encore commandable), et des médias (images).
- **Prix par quantité** (`product_prices`) : chaque palier de quantité a son propre prix et un flag `active_price`. Un prix désactivé (`active_price = false`) n'est **pas supprimé** — juste masqué. Les endpoints publics/client ne renvoient que les prix actifs ; `admin` et `cabine` voient tous les prix (actifs et inactifs) pour la gestion complète. Le frontend filtre aussi côté client par sécurité (`filter(p => p.active_price !== false)`).
- Désactiver un prix dans l'UI admin (retirer un prix existant) doit désactiver, pas supprimer — cohérence avec l'historique des commandes passées qui référencent ce prix.
## Panier et stock
- Le panier (`baskets`) vérifie le stock disponible à l'ajout (`AddToBasket`, rejet si insuffisant) mais ne le réserve pas au sens strict (pas de verrou tant que l'article reste dans le panier) — le stock réel n'est **décrémenté qu'à la validation de la commande** (checkout), dans une transaction unique avec la création de la commande et le vidage du panier.
- Un modèle `StockInfo` distingue `Quantity` (stock brut), `Reserved` (quantité présente dans des paniers actifs, à titre indicatif) et `Available` (`Quantity - Reserved`) — utilisé pour l'affichage admin, pas comme mécanisme de réservation dur.
- **Articles récompense** (`is_reward = true`, obtenus via le système de points, prix affiché = 0€ mais valeur indicative dans `RewardItem.Price`) : ce sont des produits physiques réellement distribués. **Le stock doit être décrémenté pour eux comme pour un article payant**, et remboursé de la même façon en cas d'annulation. Ne jamais les exclure du décompte de stock — seule leur tarification (débit en points au lieu d'euros) diffère.
- **Symétrie obligatoire** : toute décrémentation de stock doit avoir un chemin de remboursement, et vice-versa, **pour tous les articles sans exception** (récompense ou non). Une asymétrie désynchronise durablement le stock affiché de la réalité physique — c'est la classe de bug la plus dangereuse et la plus difficile à détecter de ce projet (corruption silencieuse, cumulative, visible seulement des semaines plus tard).
- Toute commande annulée deux fois (retry réseau, double-tap, ou canaux différents pour la même commande) ne doit rembourser le stock **qu'une seule fois** → nécessite un statut "already cancelled" idempotent vérifié **dans** une transaction verrouillée (`FOR UPDATE`), pas une simple vérification préalable hors transaction.
- Créer la commande + insérer les items + décrémenter le stock + vider le panier doivent être **une seule transaction** — sinon une commande "fantôme" (créée mais jamais payée en stock) peut survivre à un échec de décrément, puis être annulée plus tard et rembourser un stock jamais consommé.
## Paramètres globaux configurables (`AppSettings`)
Presque toutes les règles business ci-dessous sont **pilotées par un objet de settings unique**, modifiable par l'admin (`GET/PUT /api/v2/admin/protected/settings`) — ne jamais coder en dur une valeur qui existe déjà comme champ de `AppSettings` :
| Domaine | Champs | Notes |
|---|---|---|
| Pénalités | `PenaltiesEnabled`, `ShowAmendeScore`, `PenaltyTiers[]` | Tiers par défaut : 0→20€, 1→50€, 2→100€, 3→150€ (voir section Pénalités) |
| Points | `PointsEnabled`, `PointsPools[]`, `PointsReward` | Pools par défaut : "Pool 1"/"Pool 2" avec barèmes différents (voir section Points) |
| Parrainage | `ReferralEnabled`, `ReferralAmount` | Montant crédité par défaut = 0 (doit être configuré par l'admin) |
| Paiement crypto | `CryptoPaymentEnabled`, `CryptoOnly`, `NowPaymentsAPIKey`, `NowPaymentsIPNSecret`, `NowPaymentsCurrencies[]` | `CryptoOnly = true` désactive le cash |
| Livraison | `DeliverySchedule` (horaires par jour), `PostalZones[]` (nom, minimum de commande, codes postaux), `DeliveryMode` | Voir sections dédiées |
| Telegram | `TelegramBotToken`, `TelegramBotUsername`, `TelegramNotificationsEnabled`, `Telegram2FAEnabled` | |
| Vitrine | `ShopName` (def. "Milieu-Nantais"), `ContactTelegram` (def. "MLN44LA"), couleurs admin/client, dégradé titre | Purement cosmétique |
Toute nouvelle règle configurable doit suivre ce même modèle (ajout d'un champ `AppSettings` + valeur par défaut dans `DefaultSettings()`) plutôt qu'une constante Go.
## Système de points et récompenses (multi-pool)
- **Plusieurs "pools" de points** peuvent coexister, chacun associé à un sous-ensemble de catégories de produits (`PointsPool.Categories`) et avec son propre barème (`Tiers` : palier de montant dépensé → points gagnés, ex. 3050€ → 1 point, 401€+ → 10 points). Un même achat peut alimenter un pool différent selon la catégorie du produit acheté.
- Les points cumulés par pool sont stockés hors table `clients` classique (`points_extra`/`points_redeemed`, champs calculés `gorm:"-"`) — lus via `GetClientPointsAndRewards`.
- **Récompense globale par seuil** (`PointsReward`) : un seuil de points (`Threshold`) débloque une récompense, dont l'éligibilité est filtrée par catégorie/produits (`CategoryConfigs`) **par pool** (seules les catégories appartenant au pool comptent). Le nombre de récompenses disponibles = `points_du_pool / Threshold - déjà_réclamées`.
- **Réclamation** (`POST` claim, `ClaimMyReward`) : ajoute les `RewardItems` définis (produit + quantité) au panier avec `is_reward = true` et `reward_pool_key` renseigné — c'est le seul mécanisme qui produit des articles récompense. Consomme une unité de récompense disponible pour ce pool (`points_redeemed` incrémenté).
- L'admin peut réinitialiser les récompenses réclamées d'un client pour un pool donné (`AdminResetClientRedeemed`).
## Parrainage (parrain/filleul)
- Un client peut être parrainé par un autre (`clients.parrain`). Lier un parrain + créditer le crédit de parrainage (`referral_balance`, montant = `AppSettings.ReferralAmount`) doit être **atomique** (une seule transaction) — sinon un crédit peut être appliqué sans lien enregistré ou l'inverse.
- Le crédit de parrainage se débite au checkout (`DebitReferralBalance`) et doit respecter le minimum de la zone de livraison **après** déduction du crédit (le panier effectif payé doit rester ≥ minimum de la zone du code postal, `PostalZones`).
- Si le checkout échoue après débit du crédit (paiement crypto refusé, création de commande en échec), le crédit doit être **recrédité** (`CreditClientReferral`) — sinon perte sèche pour le client.
- Le système peut être entièrement désactivé (`ReferralEnabled = false`) — vérifier ce flag avant d'exposer une action de parrainage.
## Pénalités clients (amendes)
- Amendes **client uniquement**, jamais de pénalité livreur. Stockées dans `clients.amende`, avec compteur `cancellations_count` et `last_penalty_reason`.
- Barème progressif **configurable** (`AppSettings.PenaltyTiers`, fallback interne si settings illisibles) — défaut : 1ère annulation 20€, 2ème 50€, 3ème 100€, 4ème+ 150€. Le montant appliqué = `penaltyForCount(cancellations_count, PenaltyTiers)`.
- Le système entier peut être désactivé (`PenaltiesEnabled = false`) — dans ce cas le middleware `BlockClientIfPenalty` laisse passer sans vérification.
- **Blocage du checkout** : tant que `amende > 0`, le middleware `BlockClientIfPenalty` bloque toute tentative de checkout (403), avec un cache de la pénalité en session Redis (`PenaltyCache`) pour éviter une lecture DB à chaque requête. Message standard invite à contacter le shop via Telegram pour régulariser.
- **Sources d'amende** :
- Client annule sa propre commande (`ApplyCancellationPenalty`, incrémente `cancellations_count`).
- Livreur marque le client absent depuis le statut `arrived` (bouton "Client absent", `issue_type: client_absent`) → `ApplyCancellationPenalty` appliqué automatiquement au **client**, jamais au livreur.
- Admin peut appliquer une pénalité manuelle arbitraire (`POST /admin/protected/penalty`, montant et raison libres) — indépendante du barème progressif.
- "Annulation tardive" (règle spécifique au flux client `CancelCommandAtomic`, distincte du flux "client absent" livreur) = livreur déjà assigné ET (statut `en_route`/`arrived` OU ETA valide déjà définie en Redis). Sans livreur assigné ou sans ETA valide → annulation sans pénalité.
## Mode d'assignation des livreurs
- `DeliveryMode.Mode` : `"single"` (un seul pool de livreurs, toutes catégories confondues — mode par défaut) ou `"category_based"` (chaque livreur est routé uniquement vers les commandes contenant les catégories qui lui sont assignées, via `CategoryRoutes`).
- En mode `category_based`, l'auto-assignation GPS doit filtrer les livreurs éligibles par catégorie **avant** de calculer les distances — une commande mixte (catégories de livreurs différents) est un cas limite à traiter explicitement si cette fonctionnalité est étendue.
## GPS, auto-assignation et ETA
- **Géocodage** : Nominatim (OpenStreetMap), résultat caché 7 jours (`geocode:cache:{hash}`).
- **Distance à vol d'oiseau** : formule Haversine, calculée localement, aucun appel externe.
- **ETA avec trafic réel** : TomTom Routing API. **Rotation automatique jusqu'à 3 clés** (`TOMTOM_API_KEY_1/2/3`) — en cas de quota dépassé (403/429), bascule automatique sur la clé suivante sans interruption ; si toutes les clés sont épuisées, fallback sur estimation Haversine + vitesse moyenne 30 km/h (flag `fallback_used: true` dans la réponse).
- **Auto-assignation** : au checkout (immédiate si un livreur est disponible) et via un worker CRON toutes les 1 minute pour les commandes restées `pending`. Sélectionne le livreur disponible le plus proche avec de la capacité ; si tous sont à capacité maximale, le système peut forcer l'assignation.
- **Capacité de queue** : jusqu'à **10 commandes** par livreur. Un livreur `offline` ne reçoit aucune commande.
- Position GPS livreur stockée dans Redis (`delivery:location:{username}`, TTL 2h) et diffusée en temps réel via Redis Pub/Sub (`channel:position_updates`) pour la carte client/admin.
- Liens de navigation générés vers Google Maps / Waze / Apple Maps / OSM / Bing / Here, pour le livreur comme pour l'admin (supervision).
## Paiements
- **Cash** (par défaut, sauf si `CryptoOnly = true`) : le livreur encaisse à la livraison, aucun flux électronique.
- **Crypto** (NowPayments) : commande passe en `pending_payment` en attendant confirmation. Le webhook IPN (`POST /webhooks/nowpayments`) est **public** mais signé HMAC-SHA512 (`x-nowpayments-sig`) — vérifier la signature avant tout traitement, jamais faire confiance au contenu brut. Statuts `finished`/`confirmed` → activent la commande (repasse en `pending`, entre dans le cycle normal) ; `failed`/`expired` → annulent et remboursent stock + crédit parrainage.
- Le stock est décrémenté **dès la création de la commande crypto** (avant confirmation du paiement) — une commande crypto non payée réserve quand même le stock pendant la fenêtre de paiement, et le libère si elle expire/échoue.
- `CryptoPaymentEnabled = false` désactive complètement l'option crypto au checkout ; `CryptoOnly = true` la rend obligatoire.
## Alertes police (sécurité opérationnelle livreur)
- Un livreur peut déclencher une **alerte police** à tout moment (`POST /livreur/alert`, message optionnel) — notifie immédiatement tous les admins et cabine (`NotifyAllAdminCabineAlert`). C'est un bouton de sécurité personnelle, pas lié à une commande précise.
- Les alertes peuvent être supprimées par le livreur qui les a créées ou par un admin.
## Notifications et 2FA
- **Telegram uniquement** — les push Expo sont abandonnées (v5.4.0). Clients, livreurs, admins lient leur compte via un token à usage unique (TTL court, ex. 5 min).
- Types de notifications : `assigned`, `en_route` (avec ETA), `arrived`, `livre`, `ready_pickup` (cabine), `address_proposal`.
- 2FA (client) : nécessite Telegram lié + activation admin globale (`Telegram2FAEnabled`) + toggle personnel du client. Code 6 chiffres, `session_token` TTL 5 min, rate-limité (429 après trop de tentatives).
- Le système de notifications peut être désactivé globalement (`TelegramNotificationsEnabled = false`).
## Infrastructure (contexte pour évaluer l'impact d'un changement)
- Serveurs séparés reliés par VPN WireGuard privé (`10.0.0.0/24`) : `vpn-uber` (jump host), `monitoring-uber` (Wazuh/Dozzle/Beszel), `backup-mln` (MinIO S3 + ClamAV), `bdd-redis-prod` (PostgreSQL + Redis, **jamais exposé publiquement**), `prod-uber` (backend + WAF, seul serveur public sur 80/443).
- PostgreSQL et Redis accessibles uniquement via IP VPN (`10.0.0.5`) depuis `prod-uber`**latence réseau non négligeable**, d'où l'importance de grouper les requêtes (batch inserts, requêtes `IN`, parallélisation des stats déjà faites dans ce projet).
- WAF nginx + ModSecurity (OWASP CRS) devant l'API en prod ; logs nginx/ModSecurity montés sur l'hôte pour collecte Wazuh.
- Déploiement : push sur `pre-prod` → CI build image Docker (`xor1234/backend-mln:pre-prod`) → déploiement SSH.
- Workers automatiques : auto-assignation (1 min), nettoyage queues (5 min), mise à jour ETA (30 s), nettoyage stock (5 min).
## Erreurs passées à ne pas reproduire (mémoire vive du projet)
- Vider le panier **avant** de décrémenter le stock (au lieu d'une seule transaction) → stock jamais décrémenté en pratique.
- Restaurer le stock sans vérifier le statut précédent dans une transaction verrouillée → double remboursement sur double-annulation (le livreur avait ce bug, l'admin ne l'avait pas — incohérence entre chemins de code équivalents).
- Créer la commande + insérer les items **avant** la transaction de décrément de stock → commande fantôme si le décrément échoue (stock insuffisant détecté trop tard), qui peut ensuite être annulée et rembourser un stock jamais consommé.
- Exclure les articles récompense du décompte de stock sans les exclure aussi du remboursement (ou l'inverse) → asymétrie, stock qui dérive. Règle définitive validée par l'équipe : **les récompenses décrémentent et remboursent le stock exactement comme un article payant**.
- Coder en dur une valeur métier (barème de pénalité, montant de parrainage, seuil de points) qui existe déjà comme champ configurable dans `AppSettings` — toujours lire les settings, ne jamais dupliquer une constante.
@@ -0,0 +1,64 @@
---
name: plan-fonctionnalite
description: À invoquer avant d'implémenter toute nouvelle fonctionnalité ou modification significative de logique métier sur ce projet. Produit un plan détaillé (compréhension métier, sécurité, impact données, concurrence, tests) à valider avec l'utilisateur avant d'écrire du code — n'implémente rien tant que le plan n'est pas approuvé.
---
# Plan de développement de fonctionnalité
Ce skill encadre le développement de toute fonctionnalité non triviale sur ce projet. Règle centrale : **pas de code avant un plan validé par l'utilisateur**, sauf si la demande est un pur bug fix local déjà bien compris (dans ce cas, ce skill ne s'applique pas — voir "Quand ne pas utiliser ce skill").
## Étape 0 — Charger le contexte
Avant de rédiger le plan :
1. Invoquer/relire le skill `comprehension-metier` pour ancrer le raisonnement dans les vraies règles du domaine (rôles, cycle de vie commande, stock, parrainage, pénalités, paiements).
2. Repérer le(s) rôle(s) concerné(s) par la fonctionnalité (client / admin / livreur / cabine) et les fichiers existants correspondants (`handlers/`, `db/`) pour ne pas dupliquer un mécanisme déjà présent.
3. Si la demande est ambiguë sur une règle métier (ex: "qui peut faire X", "est-ce que ça affecte le stock"), poser la question plutôt que de supposer.
## Étape 1 — Rédiger le plan
Utiliser `EnterPlanMode` si l'outil est disponible pour ce tour ; sinon présenter le plan en texte structuré et attendre confirmation explicite avant de coder. Le plan doit couvrir, dans cet ordre :
### 1. Résumé fonctionnel
Quoi, pour qui, pourquoi — en une ou deux phrases orientées métier (pas techniques).
### 2. Rôles et permissions
- Qui déclenche l'action, qui peut la voir, qui peut l'annuler/modifier.
- Nouveau endpoint ? → préciser le middleware d'auth (client vs admin/livreur/cabine) et la vérification de propriété de ressource.
### 3. Impact sur les données
- Nouvelles colonnes/tables ? Migration nécessaire (`ALTER TABLE ... IF NOT EXISTS` dans `db_init.go`, cohérent avec le style existant du projet).
- Tables existantes affectées, et sens des colonnes touchées (stock, solde, statut, compteur).
### 4. Flux détaillé
- Étapes séquencées, y compris les statuts intermédiaires si la fonctionnalité touche au cycle de vie d'une commande.
- Effets de bord obligatoires à tracer explicitement : stock (décrément/remboursement symétriques, y compris articles récompense), points de fidélité, solde de parrainage, pénalités, notifications Telegram.
### 5. Sécurité (voir skill `securite-projet` pour le détail)
- Validation d'entrée (bornes, whitelist de statuts, longueur).
- Requêtes paramétrées uniquement.
- Si paiement ou webhook externe impliqué : vérification de signature avant traitement.
- Pas de nouveau chemin d'auto-inscription ou de contournement d'autorisation.
### 6. Concurrence et atomicité
- Cette action peut-elle être rejouée (double-tap, retry réseau, webhook dupliqué) ? Si oui : mécanisme d'idempotence explicite (vérifier l'état courant avant d'agir, retourner un succès idempotent plutôt qu'une erreur ou un double effet).
- Lecture-puis-décision-puis-écriture sur une valeur partagée (stock, solde) ? → transaction unique avec `FOR UPDATE`, jamais une suite d'appels séparés.
- Toute création d'enregistrement (commande, paiement) doit être dans la **même transaction** que ses effets de bord critiques (décrément stock, débit solde) — pas de risque d'enregistrement "fantôme" si une étape suivante échoue.
### 7. Plan de test
- Cas nominal.
- Cas limite métier (stock insuffisant, solde insuffisant, commande déjà dans l'état cible, ressource appartenant à un autre utilisateur).
- Cas de concurrence si pertinent (double-tap simulé, deux requêtes quasi simultanées).
- Comment vérifier après implémentation (`go build`, `go vet`, test manuel via `/verify` ou l'app si UI concernée).
### 8. Points ouverts
Toute question métier ou technique non tranchée, à soumettre explicitement à l'utilisateur plutôt que de trancher seul par défaut.
## Étape 2 — Validation puis implémentation
Ne commencer l'implémentation qu'après retour explicite de l'utilisateur sur le plan. Si l'utilisateur ne modifie rien, considérer le plan tel quel comme approuvé. Implémenter ensuite en suivant fidèlement les sections Sécurité et Concurrence du plan — elles ne sont pas optionnelles une fois validées.
## Quand ne pas utiliser ce skill
- Bug fix ponctuel et bien circonscrit (ex: correction d'une requête, d'un typo, d'une regression déjà diagnostiquée) où un plan formel ajouterait de la friction sans valeur — corriger directement.
- Modification purement cosmétique (style, renommage local, commentaire).
- Le skill s'applique dès qu'une action touche : un nouveau statut ou transition de commande, un flux d'argent ou de points, une nouvelle route API, ou un changement de permission.
+46
View File
@@ -0,0 +1,46 @@
---
name: securite-projet
description: Checklist de sécurité spécifique à ce projet (JWT multi-rôles, 2FA Telegram, webhook crypto NowPayments, VPN, WAF, création de comptes admin-only). À invoquer avant de merger tout code touchant à l'authentification, aux paiements, aux endpoints admin/livreur/cabine, ou à l'infrastructure serveur — en complément du skill générique security-review, pas à sa place.
---
# Sécurité — spécifique à ce projet
Cette checklist complète (ne remplace pas) le skill générique `security-review`. Elle encode les règles de sécurité **propres à cette plateforme**, qui ne sont pas détectables par une revue générique OWASP.
## Authentification et autorisation
- **Deux familles de JWT strictement séparées** : `USER_JWT_SECRET` (client) et `ADMIN_JWT_SECRET` (admin/livreur/cabine). Ne jamais faire valider un token d'une famille par le middleware de l'autre.
- Sessions actives trackées dans Redis (`session:{token}`, TTL 5h client / 2h admin) — la révocation d'un token doit supprimer la clé Redis correspondante, pas seulement compter sur l'expiration JWT.
- **Aucun endpoint d'auto-inscription client** ne doit exister. Si une tâche demande d'ajouter un moyen de créer un compte client hors du panel admin, c'est un signal d'alerte à soulever explicitement avant d'implémenter.
- Pour tout nouvel endpoint livreur/cabine : vérifier le rôle **et** la propriété de la ressource (`livreur_assign == username`), jamais le rôle seul. C'est l'erreur la plus fréquente dans ce code : un livreur authentifié valide ne doit agir que sur ses propres commandes.
- 2FA : le `session_token` de vérification (TTL 5 min) et le code à 6 chiffres doivent rester **rate-limités** (429 après trop de tentatives) — ne jamais retirer ce rate limiting pour "simplifier" un flux.
## Paiements crypto (NowPayments)
- Le webhook `POST /api/v1/webhooks/nowpayments` est un endpoint **public** par nécessité (appelé par NowPayments, pas par un utilisateur authentifié). Sa seule protection est la vérification **HMAC-SHA512** de l'en-tête `x-nowpayments-sig` — ne jamais traiter un payload dont la signature ne vérifie pas, quel que soit le contenu.
- Ne jamais faire confiance à un statut de paiement transmis par le client (ex: un champ `payment_status` dans une requête utilisateur) — seul le webhook signé ou un appel serveur-à-serveur à l'API NowPayments (`GetPaymentStatus`) fait foi.
- Toute transition `pending_payment → cancelled` doit être gardée par une vérification du statut courant (`WHERE status = 'pending_payment'`) pour éviter un double remboursement de stock si le webhook est reçu plusieurs fois (NowPayments peut renvoyer le même événement).
## Requêtes base de données
- Toutes les requêtes utilisent des paramètres liés GORM (`?` binding) — **jamais** de concaténation de chaînes dans une requête `Raw`/`Exec`, y compris pour des valeurs qui semblent "internes" (statuts, IDs). Une seule exception acceptable : les noms de colonnes/tables provenant d'une liste blanche fixe dans le code, jamais d'une entrée utilisateur.
- Toute opération qui lit puis modifie un compteur/solde partagé (stock, solde de parrainage, compteur d'annulations) doit se faire dans une transaction avec `FOR UPDATE` si une décision (ex: "stock suffisant ?") dépend de la valeur lue — sinon condition de course exploitable (survente, sur-crédit).
## Infrastructure
- PostgreSQL et Redis ne sont **jamais** exposés publiquement — accessibles uniquement via le VPN WireGuard (`10.0.0.0/24`) depuis `prod-uber`. Ne jamais suggérer d'ouvrir ces ports sur l'IP publique, même temporairement pour du debug.
- Les secrets (`.env`, clés JWT, `NOWPAYMENTS_IPN_SECRET`, `TELEGRAM_WEBHOOK_SECRET`) ne doivent jamais apparaître dans un commit, un log applicatif, ou une réponse API d'erreur.
- Le WAF (nginx + ModSecurity OWASP CRS) est le point d'entrée public — toute modification de routes ou de headers doit rester compatible avec ses règles (CSP, HSTS, TLS 1.2/1.3).
- SSH restreint au VPN sur les serveurs sensibles (`monitoring-uber`, `backup-mln`, `bdd-redis-prod`) — jump host via `vpn-uber`. Ne jamais recommander de désactiver cette restriction.
## Checklist rapide avant de merger un changement sensible
Pour tout endpoint touchant argent, stock, statut de commande, ou compte utilisateur :
- [ ] Rôle **et** propriété de la ressource vérifiés (pas l'un sans l'autre)
- [ ] Entrées validées (bornes numériques, longueur de chaîne, whitelist de statuts)
- [ ] Requêtes paramétrées, aucune concaténation SQL
- [ ] Opération idempotente si l'action peut être rejouée (retry réseau, double-tap, webhook dupliqué)
- [ ] Transaction + verrou (`FOR UPDATE`) si lecture-puis-décision-puis-écriture sur une valeur partagée
- [ ] Pas de nouveau secret ou donnée sensible loggé en clair
- [ ] Si paiement crypto impliqué : signature webhook vérifiée avant tout traitement
+145
View File
@@ -0,0 +1,145 @@
---
name: test-logique-metier
description: À lancer systématiquement à la fin de l'implémentation de toute fonctionnalité touchant à la logique métier (stock, commandes, paiements, points, parrainage, pénalités). Démarre l'API en local, exécute une série de scénarios réels via curl contre l'API, et vérifie en base que les invariants métier tiennent (stock décrémenté puis remboursé exactement, idempotence, autorisations par rôle). Ne se contente pas de lire le code — observe le comportement réel.
---
# Test de logique métier — vérification comportementale locale
Ce skill exécute des tests **de bout en bout contre une instance locale de l'API**, pas une relecture de code. Objectif : détecter les bugs de la classe "le code compile et semble correct, mais le comportement observé diverge" — exactement le type de bugs trouvés et corrigés dans ce projet (stock jamais décrémenté, double remboursement, commande fantôme). S'appuie sur les règles métier du skill `comprehension-metier` : le lire d'abord si ce n'est pas déjà fait.
**Ne jamais exécuter ces tests contre la base pre-prod ou prod.** Uniquement contre un environnement local jetable.
## Quand l'utiliser
- À la fin de l'implémentation de toute fonctionnalité qui touche : stock, cycle de vie d'une commande, paiement (cash/crypto), points/récompenses, parrainage, pénalités, permissions par rôle.
- Après toute correction de bug dans ces domaines (pour confirmer la correction ET l'absence de régression sur les cas adjacents).
- Complément du skill `plan-fonctionnalite` (étape "plan de test" de ce skill) — celui-ci l'exécute réellement au lieu de rester une liste sur papier.
- Ne pas l'utiliser pour un changement purement cosmétique ou un fix qui ne touche aucune règle métier.
## Étape 0 — Préparer l'environnement local
```bash
# 1. Postgres + Redis locaux (depuis backend/gestion/)
cd backend/gestion
docker compose up -d
docker compose ps # attendre "healthy" sur les deux services
# 2. Variables d'environnement minimales (adapter aux valeurs du .env local)
export DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=<db_name>
export REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=<redis_password>
export USER_JWT_SECRET=$(openssl rand -hex 32)
export ADMIN_JWT_SECRET=$(openssl rand -hex 32)
# 3. Lancer l'API (dans un terminal séparé ou en arrière-plan)
go run main.go # écoute sur :8080, crée les tables au démarrage (createTables)
```
Vérifier que l'API répond avant de continuer :
```bash
curl -sf http://localhost:8080/api/v1/app-settings > /dev/null && echo "API up"
```
## Étape 1 — Obtenir un compte admin de test
**La création d'un compte admin est volontairement bloquée via l'API** (voir `comprehension-metier`) — impossible d'obtenir un token admin par un simple appel HTTP. Il faut l'insérer directement en base locale (jetable, jamais en pre-prod/prod) :
```bash
# Générer un hash bcrypt pour le mot de passe de test
HASH=$(go run -exec "" - <<'EOF' 2>/dev/null || python3 -c "import bcrypt; print(bcrypt.hashpw(b'TestPass123!', bcrypt.gensalt()).decode())"
package main
import ("fmt"; "golang.org/x/crypto/bcrypt")
func main() {
h, _ := bcrypt.GenerateFromPassword([]byte("TestPass123!"), bcrypt.DefaultCost)
fmt.Println(string(h))
}
EOF
)
docker exec -i gestion_postgres psql -U postgres -d <db_name> -c \
"INSERT INTO users (username, password, role) VALUES ('test_admin', '$HASH', 'admin') ON CONFLICT (username) DO NOTHING;"
```
Puis se connecter normalement :
```bash
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v2/admin/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"test_admin","password":"TestPass123!"}' | jq -r .access_token)
```
À partir de ce token admin, créer les comptes de test nécessaires **via l'API** (c'est le chemin normal) : client de test, livreur de test, cabine de test — jamais par insertion SQL directe pour ceux-là, afin de tester le vrai chemin de création.
## Étape 2 — Méthode générale
Pour chaque scénario : **agir via l'API (curl)**, puis **vérifier l'état réel en base** (`docker exec gestion_postgres psql ...`) plutôt que de se fier uniquement à la réponse HTTP — une réponse 200 ne prouve pas que l'effet de bord a eu lieu correctement.
Gabarit de vérification stock :
```bash
docker exec -i gestion_postgres psql -U postgres -d <db_name> -t -c \
"SELECT stock FROM products WHERE id = $PRODUCT_ID;"
```
Toujours noter le stock **avant** l'action, exécuter l'action, relire le stock **après**, et comparer à la valeur attendue calculée manuellement (pas juste "différent de avant").
## Étape 3 — Scénarios à exécuter
### Stock — commande normale
1. Créer un produit avec stock connu (ex. 10).
2. Client ajoute 3 unités au panier, checkout.
3. Vérifier : stock produit = 7 exactement.
4. Client annule la commande.
5. Vérifier : stock produit = 10 exactement (retour à la valeur initiale).
### Stock — articles récompense
1. Configurer un pool de points avec un seuil bas et un `RewardItem` pointant vers un produit à stock connu.
2. Faire gagner assez de points au client de test (achats successifs), puis réclamer la récompense (`ClaimMyReward`).
3. Checkout incluant l'article récompense.
4. Vérifier : stock décrémenté de la quantité offerte, **comme un article payant**.
5. Annuler la commande → vérifier stock restauré exactement.
### Stock — idempotence de l'annulation
1. Créer une commande, la faire annuler une première fois (client, livreur, ou admin — tester les trois chemins séparément).
2. Rejouer le même appel d'annulation une seconde fois sur la même commande.
3. Vérifier : le second appel ne modifie **pas** le stock une seconde fois (comparer stock après 1er appel et après 2e appel — doivent être identiques), et renvoie une réponse cohérente (pas une erreur qui laisserait croire à un échec silencieux).
### Stock — commande fantôme / double-submit
1. Vider le panier d'un client, y ajouter un article dont le stock est juste suffisant pour une seule commande (ex. stock = 2, quantité demandée = 2).
2. Envoyer **deux requêtes de checkout quasi simultanées** pour ce même client (deux processus curl en parallèle, `&` en shell).
3. Vérifier : une seule commande a réellement décrémenté le stock, l'autre échoue proprement (panier vide ou stock insuffisant) — **aucune commande "pending" orpheline** ne doit rester en base avec des `command_items` mais un stock jamais décrémenté pour elle.
### Paiement crypto
1. Checkout avec `payment_method: crypto` → vérifier statut `pending_payment` et stock déjà décrémenté à ce stade.
2. Simuler le webhook IPN avec statut `failed` (signature HMAC valide requise — générer avec le secret de test) → vérifier commande `cancelled` et stock restauré.
3. Répéter avec statut `finished` sur une nouvelle commande → vérifier commande repasse en `pending` (cycle normal), stock reste décrémenté.
4. Renvoyer deux fois le même webhook `failed` → vérifier pas de double remboursement.
### Parrainage
1. Lier un parrain à un client, vérifier `referral_balance` du parrain crédité du montant configuré (`ReferralAmount`).
2. Checkout du filleul avec crédit parrainage utilisé, panier tout juste au-dessus du minimum de zone + crédit → vérifier acceptation ; en dessous → vérifier rejet avec message explicite.
3. Faire échouer le checkout après débit du crédit (ex. stock insuffisant découvert tardivement) → vérifier que `referral_balance` est recrédité, pas perdu.
### Points et récompenses
1. Vérifier que les points s'accumulent dans le bon pool selon la catégorie du produit acheté (pas dans tous les pools).
2. Réclamer une récompense au-delà du nombre disponible → vérifier rejet.
3. Reset admin des récompenses réclamées d'un client → vérifier que le compteur repart à zéro et que de nouvelles réclamations redeviennent possibles.
### Pénalités
1. Simuler 4 annulations successives du même client (avec livreur assigné + statut `en_route`/`arrived` pour déclencher la pénalité) → vérifier progression exacte du barème (20€, 50€, 100€, 150€ ou barème configuré).
2. Avec `amende > 0`, tenter un checkout → vérifier blocage 403 avec message contact.
3. Simuler le flux "client absent" (livreur annule depuis `arrived`) → vérifier pénalité appliquée au **client**, jamais au livreur.
4. Annulation sans livreur assigné → vérifier absence de pénalité.
### Permissions par rôle
1. Token livreur A tente d'agir sur une commande assignée à livreur B → vérifier 403 (pas seulement vérification du rôle, vérification de la propriété).
2. Token client tente d'accéder à une route admin → 403.
3. Vérifier qu'aucun endpoint ne permet de créer un compte `admin` via l'API (tenter et confirmer le rejet/l'absence de route).
4. Vérifier que le livreur ne reçoit jamais le téléphone du client dans `GET /livreur/deliveries`.
## Étape 4 — Rapport et suite
Pour chaque scénario : **PASS** ou **FAIL** avec la preuve chiffrée (valeurs avant/après). En cas de FAIL, ce n'est pas la fin du skill — revenir au code, corriger, puis **relancer uniquement les scénarios concernés** (pas besoin de tout rejouer) jusqu'à ce que tout passe. Ne jamais considérer une fonctionnalité "terminée" avec un scénario en FAIL non expliqué.
## Nettoyage
```bash
docker compose down -v # supprime aussi les volumes (base de test jetable)
```
+84
View File
@@ -0,0 +1,84 @@
name: Backend - Build & Lint
on:
push:
branches: [main, pre-prod]
paths:
- "backend/**/**"
pull_request:
branches: [main, pre-prod]
paths:
- "backend/**/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
cache-dependency-path: backend/gestion/go.sum
- name: Download dependencies
working-directory: backend/gestion
run: go mod download
- name: 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
working-directory: backend/gestion
run: go build -v ./...
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: backend-binary
path: backend/gestion/gestion
retention-days: 7
- name: Login to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
if: github.event_name == 'push'
uses: docker/setup-buildx-action@v3
- name: Build & push backend (runtime)
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: runtime
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'latest' || 'pre-prod' }}
- name: Build & push WAF
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: ${{ github.ref == 'refs/heads/main' && 'docker-prod/backend/Dockerfile' || 'docker-pre-prod/backend/Dockerfile' }}
target: waf
push: true
tags: xor1234/backend-mln:${{ github.ref == 'refs/heads/main' && 'waf' || 'waf-pre-prod' }}
@@ -0,0 +1,155 @@
name: Frontend Admin - EAS Build
on:
push:
branches: [main, pre-prod]
paths:
- "frontend-admin/**"
pull_request:
branches: [main, pre-prod]
paths:
- "frontend-admin/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend-admin/package-lock.json
- name: Setup 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
working-directory: frontend-admin
run: npm ci
- name: Typecheck
working-directory: frontend-admin
run: npx tsc --noEmit
- name: Determine config
id: config
run: |
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
echo "profile=production" >> $GITHUB_OUTPUT
echo "channel=production-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-prod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_ADMIN_PROD }}" >> $GITHUB_OUTPUT
echo "apk_name=admin-panel-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
else
echo "profile=pre-prod" >> $GITHUB_OUTPUT
echo "channel=pre-prod-admin" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-preprod.uber-stup.club" >> $GITHUB_OUTPUT
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
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
fi
- name: Inject EAS project ID
working-directory: frontend-admin
run: |
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Select code signing certificate
working-directory: frontend-admin
run: |
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Restore Gradle cache (RustFS)
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 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
npx expo config --json > dist/expoconfig.json
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
@@ -0,0 +1,161 @@
name: Frontend Client - EAS Build
on:
push:
branches: [main, pre-prod]
paths:
- "mobile/**"
pull_request:
branches: [main, pre-prod]
paths:
- "mobile/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: mobile/package-lock.json
- name: Setup 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
working-directory: mobile
run: npm ci
- name: Typecheck
working-directory: mobile
run: npx tsc --noEmit
- name: Determine config
id: config
run: |
if [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.base_ref }}" = "main" ]; then
echo "profile=production" >> $GITHUB_OUTPUT
echo "channel=production-client" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-mobile-prod.uber-stup.club" >> $GITHUB_OUTPUT
echo "xavia_key=${{ secrets.XAVIA_KEY_MOBILE_PROD }}" >> $GITHUB_OUTPUT
echo "apk_name=mobile-production-$(date +%Y%m%d-%H%M).apk" >> $GITHUB_OUTPUT
echo "message=Production update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
else
echo "profile=pre-prod" >> $GITHUB_OUTPUT
echo "channel=pre-prod-client" >> $GITHUB_OUTPUT
echo "api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "ota_api_url=${{ secrets.PREPROD_API_URL }}" >> $GITHUB_OUTPUT
echo "xavia_url=https://ota-mobile-preprod.uber-stup.club" >> $GITHUB_OUTPUT
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
echo "message=Pre-prod update $(date +%Y%m%d-%H%M)" >> $GITHUB_OUTPUT
fi
- name: Inject EAS project ID
working-directory: mobile
run: |
jq '.expo.extra.eas.projectId = "${{ secrets.EXPO_PROJECT_ID_CLIENT }}"' app.json > app.tmp.json
mv app.tmp.json app.json
- name: Select code signing certificate
# certs/certificate.pem (committé) correspond à la clé de signature
# du serveur OTA mobile de production ; le serveur pre-prod signe
# avec une clé différente (PRIVATE_KEY_MOBILE_PREPROD côté ota-uber),
# donc les builds pre-prod doivent embarquer
# certs/certificate-preprod.pem à la place, sous peine de voir toute
# MAJ OTA rejetée silencieusement (signature invalide) sur ce canal.
working-directory: mobile
run: |
if [ "${{ steps.config.outputs.profile }}" = "pre-prod" ]; then
cp certs/certificate-preprod.pem certs/certificate.pem
fi
- name: Restore Gradle cache (RustFS)
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 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
npx expo config --json > dist/expoconfig.json
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
+64
View File
@@ -0,0 +1,64 @@
name: Frontend Web - Build & Lint
on:
push:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker-pre-prod/frontend/**"
pull_request:
branches: [main, pre-prod]
paths:
- "frontend-prep/**"
- "docker-pre-prod/frontend/**"
jobs:
build:
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 & lint
working-directory: frontend-prep
run: |
npx tsc -b --noEmit
npm run lint
- name: Build
working-directory: frontend-prep
env:
VITE_TOMTOM_API_KEY: ${{ secrets.VITE_TOMTOM_API_KEY }}
run: npm run build
- name: Login to Docker Hub
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
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
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
file: ${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'docker-prod/frontend/Dockerfile' || 'docker-pre-prod/frontend/Dockerfile' }}
push: true
tags: xor1234/frontend-mln:${{ (github.ref == 'refs/heads/main' || github.base_ref == 'main') && 'latest' || 'pre-prod' }}
build-args: |
VITE_TOMTOM_API_KEY=${{ secrets.VITE_TOMTOM_API_KEY }}
+2 -2
View File
@@ -1,8 +1,8 @@
# Expo local state (in sub-projects)
**/.expo/
easpip
ansible/
dist/
monitoring
docker-prod/
.ssh
mc_utilisation
monitoring/*/certs
-339
View File
@@ -1,339 +0,0 @@
# Infrastructure — Projet Gestion Commande
## Schéma global
```mermaid
graph TB
%% ─── Clients ───────────────────────────────────────────────
subgraph Clients["Clients"]
WEB["🌐 Web\nReact / Vite\n(frontend-prep)"]
MOB["📱 Mobile client\nReact Native / Expo\n(mobile/)"]
ADM["📱 Mobile admin\nReact Native / Expo\n(frontend-admin/)"]
end
%% ─── Services externes ──────────────────────────────────────
subgraph External["Services externes"]
TOMTOM["TomTom API\nMaps & routing"]
TELEGRAM["Telegram\nBot / Webhooks"]
NOWPAY["NowPayments\nCrypto IPN"]
DOCKERHUB["Docker Hub\nxor1234/backend-mln\nxor1234/frontend-mln"]
EAS["Expo EAS\nAPK builds"]
end
%% ─── CI/CD ──────────────────────────────────────────────────
subgraph CICD["CI/CD — GitHub Actions"]
GHA_B_PP["backend-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
GHA_F_PP["frontend-web-build.yml (pre-prod)\nlint → build → docker → SSH deploy"]
GHA_B["backend-build.yml (main)\nlint → build → docker → SSH deploy"]
GHA_F["frontend-web-build.yml (main)\nlint → build → docker → SSH deploy"]
GHA_A["frontend-admin-build.yml\ntypecheck → EAS APK"]
GHA_C["frontend-client-build.yml\ntypecheck → EAS APK"]
end
%% ─── VPS Pre-prod ───────────────────────────────────────────
subgraph PreProdVPS["VPS Pre-prod"]
subgraph GestionNetPP["Docker network : gestion-network"]
WAF_PP["🛡️ WAF\nnginx + ModSecurity CRS\n:80 / :443 ← public"]
BACK_PP["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
FRONT_PP["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
PG_PP["🗄️ PostgreSQL 16\n← interne"]
REDIS_PP["⚡ Redis 7\n← interne"]
end
end
%% ─── VPS Production ─────────────────────────────────────────
subgraph ProdVPS["VPS Production — mln-uber.club"]
subgraph GestionNet["Docker network : gestion-network"]
WAF["🛡️ WAF\nnginx + ModSecurity CRS\nParanoia L2\n:80 / :443 ← public"]
BACK["⚙️ Backend\nGo 1.24 + Gin\n:8080 ← interne"]
FRONT["🖥️ Frontend\nReact SPA — nginx\n:80 ← interne"]
PG["🗄️ PostgreSQL 16\ngestion_db\n← interne"]
REDIS["⚡ Redis 7\nSessions · Queue · Cache\n256 MB LRU ← interne"]
end
end
%% ─── VPS Monitoring ─────────────────────────────────────────
subgraph MonVPS["VPS Monitoring — uber-stup.club"]
subgraph MonNet["Docker network : monitoring_net"]
MNGINX["🔀 Nginx RP\n:80 / :443 ← public\ndozzle / wazuh\nrustfs / s3 / ota"]
DOZZLE["📋 Dozzle\nLogs temps réel\n:8080 ← interne"]
WAZUH_M["🔍 Wazuh Manager\n:1514 agents\n:1515 enroll\n:514 syslog"]
WAZUH_I["🗂️ Wazuh Indexer\nOpenSearch :9200"]
WAZUH_D["📊 Wazuh Dashboard\nKibana :5601"]
RUSTFS["🗃️ RustFS\nS3-compatible :9000\nConsole :9001"]
XAVIA["🚀 Xavia OTA\nNext.js :3000"]
XAVIA_DB["🗄️ PostgreSQL 16\nxavia_db ← interne"]
end
end
%% ─── Flux clients ───────────────────────────────────────────
WEB -->|HTTPS| WAF
MOB -->|HTTPS| WAF
ADM -->|HTTPS| WAF
%% ─── Routage WAF ────────────────────────────────────────────
WAF -->|"/api/*"| BACK
WAF -->|"/* SPA"| FRONT
WAF -->|"/uploads/*"| BACK
%% ─── Backend ↔ données ──────────────────────────────────────
BACK --> PG
BACK --> REDIS
%% ─── Backend ↔ services externes ────────────────────────────
BACK -->|"Geocoding / ETA"| TOMTOM
BACK -->|"Webhook"| TELEGRAM
BACK -->|"IPN callback"| NOWPAY
%% ─── CI/CD ──────────────────────────────────────────────────
GHA_B_PP -->|"push :latest + :waf"| DOCKERHUB
GHA_F_PP -->|"push :latest"| DOCKERHUB
GHA_B_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
GHA_F_PP -->|"SSH deploy (SERVER_HOST)"| PreProdVPS
GHA_B -->|"push :latest + :waf"| DOCKERHUB
GHA_F -->|"push :latest"| DOCKERHUB
GHA_B -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
GHA_F -->|"SSH deploy (SERVER_HOST_PROD)"| ProdVPS
GHA_A -->|"eas build android"| EAS
GHA_C -->|"eas build android"| EAS
DOCKERHUB -->|"docker pull"| WAF_PP
DOCKERHUB -->|"docker pull"| BACK_PP
DOCKERHUB -->|"docker pull"| FRONT_PP
DOCKERHUB -->|"docker pull"| WAF
DOCKERHUB -->|"docker pull"| BACK
DOCKERHUB -->|"docker pull"| FRONT
%% ─── CI/CD mobile → OTA ────────────────────────────────────
GHA_A -->|"eas build pre-prod"| XAVIA
GHA_C -->|"eas build pre-prod"| XAVIA
%% ─── Monitoring ─────────────────────────────────────────────
MNGINX --> DOZZLE
MNGINX --> WAZUH_D
MNGINX -->|"rustfs.uber-stup.club"| RUSTFS
MNGINX -->|"s3.uber-stup.club"| RUSTFS
MNGINX -->|"ota.uber-stup.club"| XAVIA
WAZUH_D --> WAZUH_I
WAZUH_M --> WAZUH_I
XAVIA --> XAVIA_DB
DOZZLE -->|"remote agent :7007"| ProdVPS
DOZZLE -->|"remote agent :7007"| PreProdVPS
```
---
## VPS Production
**Domaine** : `mln-uber.club`
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | Reverse proxy + WAF ModSecurity |
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données principale |
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions · File livreurs |
### Flux de trafic
```mermaid
flowchart TD
INET["🌐 Internet"] -->|":443 TLS 1.2/1.3"| WAF
subgraph WAF["🛡️ WAF — nginx + ModSecurity CRS L2"]
W1["Rate limit : 20 req/s/IP"]
W2["HSTS 2 ans · CSP · X-Frame"]
W3["Ban auto : SQLi / XSS / LFI / RCE"]
end
WAF -->|"/api/*"| BACK["⚙️ Backend\nGo / Gin :8080"]
WAF -->|"/* SPA"| FRONT["🖥️ Frontend\nnginx :80"]
WAF -->|"/uploads/*\n(fichiers statiques)"| BACK
BACK --> PG["🗄️ PostgreSQL 16"]
BACK --> REDIS["⚡ Redis 7\nSessions · Queue · Cache"]
```
### Volumes persistants
| Volume | Usage |
|---|---|
| `postgres_data` | Données PostgreSQL |
| `redis_data` | Persistance Redis (AOF) |
| `backend_uploads` | Fichiers uploadés — monté en `:ro` dans le WAF pour `/uploads/` |
### Variables d'environnement requises
| Variable | Valeur par défaut | Description |
|---|---|---|
| `DB_PASSWORD` | — | Mot de passe PostgreSQL |
| `DB_NAME` | `gestion_db` | Nom de la base |
| `SESSION_SECRET` | — | Secret session Gin |
| `USER_JWT_SECRET` | — | JWT clients |
| `USER_JWT_SECRET_OLD` | — | JWT clients (rotation) |
| `ADMIN_JWT_SECRET` | — | JWT admin/cabine |
| `ADMIN_JWT_SECRET_OLD` | — | JWT admin (rotation) |
| `REDIS_PASSWORD` | — | Mot de passe Redis |
| `TOMTOM_API_KEY` | — | Clé TomTom Maps |
| `TELEGRAM_WEBHOOK_URL` | — | URL webhook Telegram |
| `TELEGRAM_WEBHOOK_SECRET` | — | Secret webhook Telegram |
| `NOWPAYMENTS_IPN_SECRET` | — | Secret IPN NowPayments |
---
## VPS Pre-prod
Même stack que la production, déployé depuis la branche `pre-prod` via `SERVER_HOST` / `SERVER_SSH_KEY`.
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `gestion-waf` | `xor1234/backend-mln:waf` | **80, 443** (public) | WAF ModSecurity |
| `gestion-backend` | `xor1234/backend-mln:latest` | 8080 (interne) | API Go/Gin |
| `gestion-frontend` | `xor1234/frontend-mln:latest` | 80 (interne) | SPA React/Vite |
| `gestion-postgres` | `postgres:16-alpine` | 5432 (interne) | Base de données |
| `gestion-redis` | `redis:7-alpine` | 6379 (interne) | Cache · Sessions |
---
## VPS Monitoring
**Domaine** : `uber-stup.club`
### Services
| Conteneur | Image | Ports | Rôle |
|---|---|---|---|
| `monitoring_nginx` | `nginx:alpine` | **80, 443** (public) | Reverse proxy monitoring |
| `dozzle` | `amir20/dozzle:latest` | 8080 (interne) | Logs Docker temps réel |
| `wazuh.manager` | `wazuh/wazuh-manager:4.14.5` | 1514, 1515, 514/udp | SIEM — collecte agents |
| `wazuh.indexer` | `wazuh/wazuh-indexer:4.14.5` | 9200 (interne) | OpenSearch (stockage events) |
| `wazuh.dashboard` | `wazuh/wazuh-dashboard:4.14.5` | 5601 (interne) | Kibana (visualisation) |
| `rustfs` | `rustfs/rustfs:latest` | 9000 S3, 9001 console (internes) | Stockage objet S3-compatible (APKs) |
| `xavia` | `xaviaio/xavia-ota:latest` | 3000 (interne) | Serveur OTA Expo (Next.js) |
| `xavia_db` | `postgres:16-alpine` | 5432 (interne) | Base de données Xavia |
### Accès publics
| URL | Service |
|---|---|
| `https://dozzle.uber-stup.club` | Interface logs Docker |
| `https://wazuh.uber-stup.club` | Dashboard SIEM Wazuh |
| `https://rustfs.uber-stup.club` | Console RustFS (stockage APKs) |
| `https://s3.uber-stup.club` | API S3 RustFS |
| `https://ota.uber-stup.club` | Dashboard & API Xavia OTA |
### Xavia OTA — configuration app Expo
```json
"updates": {
"url": "https://ota.uber-stup.club/api/manifest",
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": {
"keyid": "main",
"algorithm": "rsa-v1_5-sha256"
}
}
```
Clé privée RSA 4096 stockée sur le serveur dans `/home/ubuntu/xavia-keys/private-key.pem`.
Le `certificate.pem` doit être commité dans le repo mobile sous `mobile/certs/certificate.pem`.
### Dozzle — agents distants
```mermaid
graph LR
DOZZLE["📋 Dozzle\nuber-stup.club"] -->|":7007"| A1["VPS Production\n5.181.0.112"]
DOZZLE -->|":7007"| A2["VPS Pre-prod\n185.234.9.102"]
```
---
## CI/CD
### Pipeline backend (pre-prod & main)
```mermaid
flowchart TD
PUSH["push backend/**"] --> LINT["lint\ngolangci-lint"]
LINT --> BUILD["build\ngo build ./..."]
BUILD --> ARTIFACT["artifact\nbackend-binary 7j"]
BUILD --> DOCKER{"push only?"}
DOCKER -->|oui| D1["docker build runtime\n→ xor1234/backend-mln:latest"]
DOCKER -->|oui| D2["docker build waf\n→ xor1234/backend-mln:waf"]
D1 --> DEPLOY["SSH deploy\ndocker compose pull backend waf\ndocker compose up -d --no-deps backend waf"]
D2 --> DEPLOY
style DOCKER fill:#f0f0f0
```
### Pipeline frontend web (pre-prod & main)
```mermaid
flowchart TD
PUSH["push frontend-prep/**\nou docker/frontend/**"] --> LINT["lint-typecheck\ntsc + eslint"]
LINT --> BUILD["build\nnpm run build"]
BUILD --> ARTIFACT["artifact\nfrontend-web-dist 7j"]
BUILD --> DOCKER{"push only?"}
DOCKER -->|oui| D1["docker build\n→ xor1234/frontend-mln:latest"]
D1 --> DEPLOY["SSH deploy\ndocker compose pull frontend\ndocker compose up -d --no-deps frontend"]
style DOCKER fill:#f0f0f0
```
### Pipeline mobile (main uniquement)
```mermaid
flowchart TD
PUSH_A["push frontend-admin/**"] --> TC_A["typecheck\ntsc --noEmit"]
TC_A --> EAS_A["EAS build android\n--profile production"]
EAS_A --> APK_A["artifact\nadmin-panel-android-prod-apk 14j"]
PUSH_C["push mobile/**"] --> TC_C["typecheck\ntsc --noEmit"]
TC_C --> EAS_C["EAS build android\n--profile production"]
EAS_C --> APK_C["artifact\nclient-android-prod-apk 14j"]
```
### Secrets GitHub requis
| Secret | Branche | Usage |
|---|---|---|
| `DOCKERHUB_USERNAME` | main + pre-prod | Login Docker Hub |
| `DOCKERHUB_TOKEN` | main + pre-prod | Token Docker Hub |
| `SERVER_HOST` | pre-prod | IP/hostname VPS pre-prod |
| `SERVER_HOST_PROD` | main | IP/hostname VPS production |
| `SERVER_USER` | main + pre-prod | Utilisateur SSH |
| `SERVER_SSH_KEY` | pre-prod | Clé privée SSH ED25519 pre-prod |
| `SERVER_SSH_KEY_PROD` | main | Clé privée SSH ED25519 prod |
| `COMPOSE_PATH` | main + pre-prod | Chemin absolu docker-compose-prod.yml |
| `EXPO_TOKEN` | main | Token Expo EAS |
| `EXPO_PROJECT_ID` | main | ID projet EAS admin |
| `EXPO_PROJECT_ID_CLIENT` | main | ID projet EAS client |
| `VITE_TOMTOM_API_KEY` | main + pre-prod | Clé TomTom pour build frontend |
---
## Backend — architecture interne
```mermaid
graph TD
MAIN["main.go\ninit DB · Redis · services · Gin"] --> ROUTES["routes/routes.go\npublic · client · admin · cabine · livreur"]
ROUTES --> HANDLERS["handlers/\nauth · commands · delivery\npanier · notifications · payments"]
HANDLERS --> MODELS["models/\nstructs GORM"]
HANDLERS --> DB["db/\nconnexion · migrations · queries"]
HANDLERS --> SERVICES["services/\nTomTom · Telegram · NowPayments"]
MAIN --> WORKERS["workers/\ncron_auto_assign 5min\npayment_checker 2min\nqueue_cleanup 5min"]
MAIN --> MW["middleware/\nsession · block · clock"]
```
### Rôles utilisateurs
| Rôle | Accès |
|---|---|
| `client` | Panier, commandes, profil, suivi |
| `admin` | Gestion complète (commandes, produits, livreurs, stats) |
| `cabine` | Mise à jour statut commandes + notification client |
| `livreur` | Tableau de bord livraisons, GPS, statut |
+1 -1
View File
@@ -240,7 +240,7 @@ graph TD
P3["GET /api/v1/products/category/:category"]
P4["GET /api/v1/categories"]
P5["GET /api/v1/app-settings"]
P6["POST /api/v1/webhooks/nowpayments"]
P6["POST /api/v1/webhook/nowpayments"]
P7["POST /webhook/telegram"]
end
+6
View File
@@ -0,0 +1,6 @@
img.jpg
video.mp4
s.sh
.env
uploads/
openapi.yaml
+31
View File
@@ -0,0 +1,31 @@
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- misspell
- bodyclose
- noctx
linters-settings:
gosec:
excludes:
- G104 # erreurs non vérifiées (couvertes par errcheck)
gocritic:
disabled-checks:
- appendAssign
- sloppyReassign
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
max-issues-per-linter: 50
max-same-issues: 5
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"fmt"
"gestion/models"
"gestion/utils"
"strings"
)
func (d *Database) CheckAddress(addressByUser *models.Command) error {
var correction models.Address
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
if result.Error == nil {
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
}
if !isNotFound(result.Error) {
return fmt.Errorf("checkAddress: %w", result.Error)
}
// (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 {
address := models.Address{
InvalidAddress: InvalidAddressByAdmin,
CorrectAddress: CorrectAddressByAdmin,
}
if err := d.GDB.Create(&address).Error; err != nil {
return fmt.Errorf("addAddress: %w", err)
}
return nil
}
func (d *Database) DeleteAddress(InvalidAddressByAdmin string, CorrectAddressByAdmin string) error {
result := d.GDB.Where("invalid_address = ? AND correct_address = ?", InvalidAddressByAdmin, CorrectAddressByAdmin).
Delete(&models.Address{})
if result.Error != nil {
return fmt.Errorf("deleteAddress: %w", result.Error)
}
return nil
}
func (d *Database) AllAddress() ([]models.Address, error) {
var addresses []models.Address
if err := d.GDB.Find(&addresses).Error; err != nil {
return nil, fmt.Errorf("getAllAddress: %w", err)
}
return addresses, nil
}
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
alert := models.AlertPolicy{
Username: username,
Status: "true",
Message: message,
}
if err := d.GDB.Create(&alert).Error; err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
var alert models.AlertPolicy
if err := d.GDB.First(&alert, id).Error; err != nil {
return models.AlertPolicy{}, err
}
return alert, nil
}
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
func (d *Database) DeleteAlertPolicy(id int) error {
return d.GDB.Delete(&models.AlertPolicy{}, id).Error
}
func (d *Database) EndAlert(id int) error {
result := d.GDB.Model(&models.AlertPolicy{}).
Where("id = ? AND status = 'true'", id).
Update("status", "false")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà terminée")
}
return nil
}
func (d *Database) ActivateAlert(id int) error {
result := d.GDB.Model(&models.AlertPolicy{}).
Where("id = ? AND status = 'false'", id).
Update("status", "true")
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("alerte non trouvée ou déjà active")
}
return nil
}
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
return nil, err
}
return alerts, nil
}
+271
View File
@@ -0,0 +1,271 @@
package db
import (
"fmt"
"gestion/models"
"gorm.io/gorm"
)
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
// "half_price_product" (50% de ce prix).
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
}
err := d.GDB.Raw(`
SELECT price FROM product_prices
WHERE product_id = ? AND quantity <= ? AND active_price = true
ORDER BY quantity DESC LIMIT 1`,
productID, quantity).Scan(&result).Error
if err != nil || result.Price == 0 {
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
}
return result.Price, nil
}
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
}
err := d.GDB.Raw(`
SELECT price
FROM product_prices pp
INNER JOIN products p ON pp.product_id = p.id
WHERE LOWER(p.name) = LOWER(?)
AND LOWER(p.category) = LOWER(?)
AND pp.quantity <= ?
ORDER BY pp.quantity DESC
LIMIT 1`, name, category, quantity).Scan(&result).Error
if err != nil || result.Price == 0 {
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
}
return result.Price, nil
}
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
var baskets []models.Panier
err := d.GDB.Raw(`
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
p.name as product_name, p.category, p.description
FROM baskets b
INNER JOIN products p ON b.product_id = p.id
WHERE b.username = ?
ORDER BY b.created_at DESC`, username).Scan(&baskets).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
}
return baskets, nil
}
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
// Supprime les anciens items récompense avant d'insérer les nouveaux.
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, 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
}
return baskets, nil
}
// addRewardsToBasketTx contient la logique de remplacement des articles
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
// soit dans la même transaction qu'une autre opération (voir
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
// 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 productInfo struct {
Stock float64 `gorm:"column:stock"`
Category string `gorm:"column:category"`
}
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
return fmt.Errorf("erreur lecture stock: %w", err)
}
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)
}
// Une promotion active pour ce produit/quantité/catégorie s'applique
// automatiquement au prix facturé — indépendamment des points de
// fidélité (contrairement aux récompenses par palier). Le montant
// économisé est conservé (promoDiscount) pour les statistiques
// admin, indépendamment de la config de promo courante au moment où
// ces stats seront consultées.
var promoDiscount float64
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
promoDiscount = priceResult.Price - discounted
priceResult.Price = discounted
}
// Offre "achetez X, Y offert" : le client reçoit une quantité
// supplémentaire du même produit, gratuite, sans changer le prix déjà
// calculé sur la quantité demandée — la quantité livrée/décomptée du
// stock est donc supérieure à la quantité facturée.
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
deliveredQuantity := quantity + freeQuantity
if productInfo.Stock < deliveredQuantity {
return fmt.Errorf("stock insuffisant")
}
var existing struct {
ID int `gorm:"column:id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// Chercher uniquement un item normal (non-récompense) pour ce produit
tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`,
username, productID).Scan(&existing)
if existing.ID != 0 {
return tx.Raw(`
UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP
WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
}
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
})
if err != nil {
return nil, err
}
return &basket, nil
}
// 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 {
return fmt.Errorf("erreur lors de la suppression du produit: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit non trouvé dans le panier")
}
return nil
}
// ClearBasket vide complètement le panier d'un utilisateur.
// Le stock n'est pas restitué car il n'a pas été décrémenté à l'ajout.
func (d *Database) ClearBasket(username string) error {
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
}
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
if err != nil {
return "", err
}
if username == "" {
return "", fmt.Errorf("article non trouvé")
}
return username, nil
}
// 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) {
var items []map[string]any
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 {
return nil, err
}
return items, nil
}
+412
View File
@@ -0,0 +1,412 @@
package db
import (
"fmt"
"log"
"slices"
"gorm.io/gorm"
)
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
if cmdResult.Username != username {
return fmt.Errorf("commande ne vous appartient pas")
}
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
return fmt.Errorf("impossible d'annuler")
}
isLateCancel := false
if cmdResult.LivreurAssign != "" {
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
} else if d.CheckCommandETAExistsAndValid(commandID) {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", cmdResult.Status)
}
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
}
if isLateCancel && !force {
return fmt.Errorf("confirmation requise")
}
cancelMsg := reason
if cancelMsg == "Annulation par le client" {
cancelMsg = ""
}
result := tx.Exec(`
UPDATE commandes
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée")
}
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
if err := tx.Exec(`
UPDATE products p
SET stock = stock + 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)
}
log.Printf("✅ [CancelAtomic] Stock remboursé")
if err := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
}
if isLateCancel {
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
penalty, _ = d.CalculateCancellationPenalty(username)
if err := tx.Exec(`
UPDATE clients
SET amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username).Error; err != nil {
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
} else {
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
commandID,
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
username,
).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
}
// Nettoyage async après commit
livreur := cmdResult.LivreurAssign
if livreur != "" {
go func() {
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
}
}()
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", username),
fmt.Sprintf("client:%s:commands", username),
)
}()
return nil
})
if err != nil {
return 0, err
}
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
return penalty, nil
}
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
if err != nil || len(etaData) == 0 {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false
}
var etaMinutes int
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
return false
}
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
if err != nil || ttl <= 0 {
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
return false
}
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
return true
}
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ 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(`
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 {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else {
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
}
} else {
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
}
// ✅ Log suppression
tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "deleted",
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
deletedBy)
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
return err
}
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
livreur := cmdResult.LivreurAssign
cmdUsername := cmdResult.Username
if livreur != "" {
go d.RemoveCommandFromAllQueues(commandID, livreur)
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", cmdUsername),
fmt.Sprintf("client:%s:commands", cmdUsername),
)
}()
return nil
})
}
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
commandIDStr := fmt.Sprintf("%d", commandID)
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
return 0, fmt.Errorf("commande non trouvée dans la queue")
}
return int(rank) + 1, nil
}
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]any, error) {
query := `
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
FROM commandes
WHERE status = 'cancelled'`
args := []any{}
if username != "" {
query += " AND username = ?"
args = append(args, username)
}
query += " ORDER BY updated_at DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur récupération: %w", err)
}
return commands, nil
}
// AddClientPenalty ajoute une pénalité à un client
func (d *Database) AddClientPenalty(username string, points int) error {
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
if points <= 0 {
return fmt.Errorf("points invalides: %d", points)
}
if username == "" {
return fmt.Errorf("username vide")
}
result := d.GDB.Exec(`
UPDATE clients
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, points, username)
if result.Error != nil {
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé: %s", username)
}
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// 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
}
+104
View File
@@ -0,0 +1,104 @@
package db
import (
"fmt"
"regexp"
"time"
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
type Category struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"column:name"`
Color string `json:"color" gorm:"column:color"`
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
Position int `json:"position" gorm:"column:position"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
func (Category) TableName() string { return "categories" }
func ValidateCategoryColor(color string) error {
if color == "" {
return nil
}
if !hexColorRegex.MatchString(color) {
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
}
return nil
}
func (d *Database) GetAllCategories() ([]Category, error) {
var categories []Category
if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
return nil, err
}
if categories == nil {
categories = []Category{}
}
return categories, nil
}
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
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 {
return nil, err
}
return &c, nil
}
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
if err := d.GDB.First(&c, id).Error; err != nil {
return nil, err
}
if err := d.GDB.Model(&c).Updates(map[string]interface{}{"name": name, "color": color, "is_coming_soon": isComingSoon}).Error; err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) DeleteCategory(id int) error {
var count int64
d.GDB.Table("products").
Where("category = (SELECT name FROM categories WHERE id = ?)", id).
Count(&count)
if count > 0 {
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
}
result := d.GDB.Delete(&Category{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("catégorie non trouvée")
}
return nil
}
// 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) {
var count int64
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
return count > 0, err
}
+825
View File
@@ -0,0 +1,825 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strings"
"time"
"gorm.io/gorm"
)
func (d *Database) CreateClient(client *models.Client) error {
var result struct {
ID int `gorm:"column:id"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
INSERT INTO clients (username, password, nom, prenom, telephone, command, amende, must_change_password, created_at)
VALUES (?, ?, ?, ?, ?, 0, 0.0, ?, CURRENT_TIMESTAMP)
RETURNING id, created_at`,
client.Username, client.Password, client.Nom, client.Prenom, client.Telephone, client.MustChangePassword,
).Scan(&result).Error
if err != nil {
return fmt.Errorf("erreur lors de la création du client: %w", err)
}
client.ID = result.ID
client.CreatedAt = result.CreatedAt
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
return nil
}
// GetClientByID récupère un client par son ID
func (d *Database) GetClientByID(id int) (*models.Client, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende,
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
FROM clients WHERE id = ?`, id).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, fmt.Errorf("client non trouvé")
}
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
CreatedAt: row.CreatedAt,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
return client, nil
}
// GetAllClients récupère tous les clients
func (d *Database) GetAllClients() ([]*models.Client, error) {
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
ReferralBalance float64 `gorm:"column:referral_balance"`
CancellationsCount int `gorm:"column:cancellations_count"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, referral_balance,
COALESCE(cancellations_count, 0) as cancellations_count,
COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at
FROM clients ORDER BY created_at DESC`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
}
clients := make([]*models.Client, 0, len(rows))
for _, row := range rows {
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
ReferralBalance: row.ReferralBalance,
CancellationsCount: row.CancellationsCount,
CreatedAt: row.CreatedAt,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
clients = append(clients, client)
}
return clients, nil
}
// UpdateClient met à jour un client existant
func (d *Database) UpdateClient(client *models.Client) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", client.ID).Updates(map[string]any{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"amende": client.Amende,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du client: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// DeleteClient supprime un client
func (d *Database) DeleteClient(id int) error {
_ = d.RevokeAllUserTokens(id, "client")
result := d.GDB.Delete(&models.Client{}, id)
if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression du client: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UpdateClientPassword met à jour le mot de passe d'un client
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("password", hashedPassword)
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UpdateClientPasswordAndClearFlag met à jour le mot de passe et remet must_change_password à false
func (d *Database) UpdateClientPasswordAndClearFlag(clientID int, hashedPassword string) error {
result := d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Updates(map[string]any{
"password": hashedPassword,
"must_change_password": false,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) GetClientAmende(username string) (float64, error) {
var result struct {
Amende float64 `gorm:"column:amende"`
}
err := d.GDB.Raw(`SELECT COALESCE(amende, 0) as amende FROM clients WHERE username = ?`, username).Scan(&result).Error
if err != nil {
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
}
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, result.Amende)
return result.Amende, nil
}
// IncrementClientCommandCount incrémente le compteur de commandes du client
func (d *Database) IncrementClientCommandCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).UpdateColumn("command", gorm.Expr("command + 1"))
if result.Error != nil {
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) AddClientPointsByCategory(username string, points int, poolKey string) error {
if poolKey == "" {
poolKey = "pool_0"
}
result := d.GDB.Exec(`
UPDATE clients
SET points_extra = jsonb_set(
COALESCE(points_extra, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, points, username)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'ajout de points: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ %d points (key=%s) ajoutés au client %s", points, poolKey, username)
return nil
}
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
var totalPoints int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
return err
}
totalPoints = points
return nil
})
if err != nil {
return 0, err
}
return totalPoints, nil
}
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
CreatedAt time.Time `gorm:"column:created_at"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende, created_at
FROM clients WHERE telephone = ?`, telephone).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, nil
}
return &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
CreatedAt: row.CreatedAt,
}, nil
}
// GetClientByUsername récupère un client par son username
// 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) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Password string `gorm:"column:password"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
Telephone string `gorm:"column:telephone"`
Command int `gorm:"column:command"`
Amende float64 `gorm:"column:amende"`
MustChangePassword bool `gorm:"column:must_change_password"`
PointsExtraJSON []byte `gorm:"column:points_extra"`
CreatedAt time.Time `gorm:"column:created_at"`
TwoFAEnabled bool `gorm:"column:two_fa_enabled"`
}
err := d.GDB.Raw(`
SELECT id, username, password, nom, prenom, telephone, command, amende,
must_change_password, COALESCE(points_extra, '{}'::jsonb) as points_extra, created_at,
two_fa_enabled
FROM clients WHERE username = ?`, username).Scan(&row).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
}
if row.ID == 0 {
return nil, nil
}
client := &models.Client{
ID: row.ID,
Username: row.Username,
Password: row.Password,
Nom: row.Nom,
Prenom: row.Prenom,
Telephone: row.Telephone,
Command: row.Command,
Amende: row.Amende,
MustChangePassword: row.MustChangePassword,
CreatedAt: row.CreatedAt,
TwoFAEnabled: row.TwoFAEnabled,
}
client.PointsExtra = map[string]int{}
if len(row.PointsExtraJSON) > 0 {
json.Unmarshal(row.PointsExtraJSON, &client.PointsExtra)
}
return client, nil
}
func (d *Database) SetClientTwoFAEnabled(clientID int, enabled bool) error {
return d.GDB.Model(&models.Client{}).Where("id = ?", clientID).Update("two_fa_enabled", enabled).Error
}
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]any, error) {
amende, err := d.GetClientAmende(username)
if err != nil {
return nil, err
}
cancellationsCount, err := d.GetClientCancellationsCount(username)
if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
cancellationsCount = 0
}
cancellationHistory, err := d.GetClientCancellationHistory(username)
if err != nil {
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
cancellationHistory = map[string]any{
"cancellations_count": cancellationsCount,
"next_penalty": 20,
}
}
info := map[string]any{
"username": username,
"total_penalty": amende,
"cancellations_count": cancellationsCount,
"cancellation_history": cancellationHistory,
"has_penalties": amende > 0,
}
return info, nil
}
// ResetClientPoint réinitialise les points d'un client.
// extraPoolKey != "" → reset points_extra[extraPoolKey] uniquement
// extraPoolKey == "" (poolIdx=-1) → reset total points_extra
func (d *Database) ResetClientPoint(username string, poolIdx int, extraPoolKey string) error {
if extraPoolKey != "" {
err := d.GDB.Exec(`
UPDATE clients SET points_extra = points_extra - ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, extraPoolKey, username).Error
if err != nil {
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE extra: %v", err)
} else {
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
}
return err
}
// -1 ou poolIdx sans clé → reset total
result := d.GDB.Exec(`
UPDATE clients SET points_extra = '{}'::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username)
if result.Error != nil {
log.Printf("❌ [ResetClientPointAdmin] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset points: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
func (d *Database) ResetClientPenalties(username string, _ bool) error {
log.Printf("🔄 [ResetClientPenalties] Reset amende + cancellations_count pour %s", username)
result := d.GDB.Exec(
`UPDATE clients SET amende = 0, cancellations_count = 0, updated_at = CURRENT_TIMESTAMP WHERE username = ?`,
username,
)
if result.Error != nil {
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur reset pénalités: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
func (d *Database) GetAllClientsWithPenalties() ([]map[string]any, error) {
var rows []struct {
Username string `gorm:"column:username"`
Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"`
UpdatedAt any `gorm:"column:updated_at"`
}
err := d.GDB.Raw(`
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
FROM clients
WHERE amende > 0
ORDER BY amende DESC`).Scan(&rows).Error
if err != nil {
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération clients: %w", err)
}
clients := make([]map[string]any, 0, len(rows))
for _, row := range rows {
clients = append(clients, map[string]any{
"username": row.Username,
"total_penalty": row.Amende,
"cancellations_count": row.CancellationsCount,
"last_updated": row.UpdatedAt,
})
}
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
return clients, nil
}
func (d *Database) GetClientPenaltiesStats() (map[string]any, error) {
var result struct {
ClientsWithPenalties int `gorm:"column:clients_with_penalties"`
TotalPenalties float64 `gorm:"column:total_penalties"`
AvgPenalty float64 `gorm:"column:avg_penalty"`
MaxPenalty float64 `gorm:"column:max_penalty"`
TotalClients int `gorm:"column:total_clients"`
}
err := d.GDB.Raw(`
SELECT
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
COALESCE(SUM(amende), 0) as total_penalties,
COALESCE(AVG(amende), 0) as avg_penalty,
COALESCE(MAX(amende), 0) as max_penalty,
COUNT(*) as total_clients
FROM clients`).Scan(&result).Error
if err != nil {
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
stats := map[string]any{
"clients_with_penalties": result.ClientsWithPenalties,
"total_penalties": result.TotalPenalties,
"average_penalty": result.AvgPenalty,
"max_penalty": result.MaxPenalty,
"total_clients": result.TotalClients,
}
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
result.ClientsWithPenalties, result.TotalClients)
return stats, nil
}
func (d *Database) CalculateAndAddPointsForCommandTx(tx *gorm.DB, commandID int, username string) (int, string, error) {
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
// Charger les paramètres globaux
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
settings = DefaultSettings()
}
pools := settings.PointsPools
if len(pools) == 0 {
log.Printf("️ [CalcPointsTx] Aucun pool configuré → 0 points")
return 0, "", nil
}
// Construire la map catégorie → index de pool
catToPool := make(map[string]int)
for i, pool := range pools {
for _, cat := range pool.Categories {
catToPool[strings.ToLower(cat)] = i
}
}
if len(catToPool) == 0 {
log.Printf("️ [CalcPointsTx] Aucune catégorie assignée aux pools → 0 points")
return 0, "", nil
}
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
var items []struct {
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
Category string `gorm:"column:category"`
}
if err := tx.Raw(`
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') as category
FROM command_items ci
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = ?
`, commandID).Scan(&items).Error; err != nil {
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
}
if len(items) == 0 {
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
return 0, "", nil
}
poolTotals := make([]float64, len(pools))
for _, item := range items {
catLower := strings.ToLower(item.Category)
if poolIdx, ok := catToPool[catLower]; ok {
poolTotals[poolIdx] += item.Prix
}
}
for i, t := range poolTotals {
log.Printf("📊 [CalcPointsTx] Pool[%d] (%s): %.2f€", i, pools[i].Name, t)
}
// ✅ ÉTAPE 2: Calculer les points pour tous les pools
var totalPoints int
var pointCategory string
poolPts := make([]int, len(pools))
var categoryParts []string
for i, pool := range pools {
poolPts[i] = CalcPointsFromTiers(poolTotals[i], pool.Tiers)
totalPoints += poolPts[i]
if poolPts[i] > 0 {
categoryParts = append(categoryParts, pool.Name)
}
}
log.Printf("💰 [CalcPointsTx] points par pool: %v, total=%d", poolPts, totalPoints)
if totalPoints == 0 {
return 0, "", nil
}
if len(categoryParts) > 0 {
pointCategory = strings.Join(categoryParts, " & ")
} else {
pointCategory = "points"
}
for i, pool := range pools {
if poolPts[i] == 0 {
continue
}
if err := tx.Exec(`
UPDATE clients
SET points_extra = jsonb_set(
COALESCE(points_extra, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_extra->>?)::int, 0) + ?)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, pool.Key, pool.Key, poolPts[i], username).Error; err != nil {
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points_extra pool[%d] (%s): %v", i, pool.Key, err)
return 0, "", fmt.Errorf("erreur mise à jour points pool[%d]: %w", i, err)
}
log.Printf("💰 [CalcPointsTx] pool[%d] (%s / key=%s): +%d pts", i, pool.Name, pool.Key, poolPts[i])
}
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
// ✅ É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
}
func (d *Database) CanUserAccessCommand(
commandID int,
username string,
role string,
) (bool, error) {
// 👑 Admin : accès total
if role == "admin" {
return true, nil
}
var exists bool
// 🚚 Livreur : seulement commandes assignées
if role == "livreur" {
err := d.GDB.Raw(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = ? AND livreur_assign = ?
)
`, commandID, username).Scan(&exists).Error
return exists, err
}
// 👤 User : seulement SES commandes
err := d.GDB.Raw(`
SELECT EXISTS(
SELECT 1 FROM commandes
WHERE id = ? AND username = ?
)
`, commandID, username).Scan(&exists).Error
return exists, err
}
// 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
}
+536
View File
@@ -0,0 +1,536 @@
package db
import (
"fmt"
"log"
"slices"
"strings"
"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"`
PromoDiscount float64 `gorm:"column:promo_discount"`
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
func validateCommandID(commandID int) error {
if commandID <= 0 {
return fmt.Errorf("ID commande invalide: %d", commandID)
}
if commandID > 2147483647 {
return fmt.Errorf("ID commande trop grand")
}
return nil
}
func validateItemID(itemID int) error {
if itemID <= 0 {
return fmt.Errorf("ID item invalide: %d", itemID)
}
if itemID > 2147483647 {
return fmt.Errorf("ID item trop grand")
}
return nil
}
func validateQuantite(quantite float64) error {
if quantite <= 0 {
return fmt.Errorf("quantité doit être > 0")
}
if quantite > 10000 {
return fmt.Errorf("quantité trop élevée (max 10000)")
}
return nil
}
func validatePrix(prix float64) error {
if prix <= 0 {
return fmt.Errorf("prix doit être > 0")
}
if prix > 100000 {
return fmt.Errorf("prix trop élevé (max 100000)")
}
return nil
}
func validateUsername(username string) error {
if len(username) == 0 {
return fmt.Errorf("username vide")
}
if len(username) > 100 {
return fmt.Errorf("username trop long (max 100)")
}
// Sanitize
if strings.Contains(username, "..") || strings.Contains(username, "/") {
return fmt.Errorf("username invalide")
}
return nil
}
func validateDeliveryAddress(address string) error {
if len(address) == 0 {
return fmt.Errorf("adresse vide")
}
if len(address) > 500 {
return fmt.Errorf("adresse trop longue (max 500)")
}
return nil
}
func validateItemStatus(status string) error {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
status = strings.ToLower(strings.TrimSpace(status))
if !slices.Contains(validStatuses, status) {
return fmt.Errorf("statut invalide: %s", status)
}
return nil
}
// ============================================
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
// ============================================
func (d *Database) InsertCommandItemWithClientInfo(
commandID int,
produit string,
productID int,
quantite float64,
prix float64,
isReward bool,
rewardPoolKey string,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
) error {
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
// ✅ VALIDATION COMPLÈTE
if err := validateCommandID(commandID); err != nil {
return err
}
if err := validateProductID(productID); err != nil {
return err
}
if err := validateQuantite(quantite); err != nil {
return err
}
// Les articles récompense ont prix=0, on saute la validation de prix pour eux
if !isReward {
if err := validatePrix(prix); err != nil {
return err
}
}
if err := validateUsername(clientUsername); err != nil {
return err
}
if err := validateDeliveryAddress(deliveryAddress); err != nil {
return err
}
// ✅ SANITIZE STRINGS
produit = strings.TrimSpace(produit)
if len(produit) > 200 {
produit = produit[:200]
}
clientNom = strings.TrimSpace(clientNom)
if len(clientNom) > 100 {
clientNom = clientNom[:100]
}
clientPrenom = strings.TrimSpace(clientPrenom)
if len(clientPrenom) > 100 {
clientPrenom = clientPrenom[:100]
}
clientTelephone = strings.TrimSpace(clientTelephone)
if len(clientTelephone) > 20 {
clientTelephone = clientTelephone[:20]
}
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; err != nil {
log.Printf("❌ Erreur vérification commande: %v", err)
return fmt.Errorf("erreur vérification commande: %w", err)
}
if !exists {
return fmt.Errorf("commande %d n'existe pas", commandID)
}
// ✅ INSERT
err := d.GDB.Exec(`
INSERT INTO command_items (
command_id, produit, product_id, quantite, prix,
is_reward, reward_pool_key,
client_username, client_nom, client_prenom, client_telephone, delivery_address,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
commandID, produit, productID, quantite, prix,
isReward, rewardPoolKey,
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
).Error
if err != nil {
log.Printf("❌ Erreur INSERT command_items: %v", err)
return fmt.Errorf("erreur insertion item: %w", err)
}
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
return nil
}
// ============================================
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
// ============================================
func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
// ✅ VALIDATION
if err := validateCommandID(commandID); err != nil {
log.Printf("❌ [GetCommandItems] %v", err)
return nil, err
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
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"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"`
}
err := d.GDB.Raw(`
SELECT
ci.id,
ci.command_id,
ci.produit,
ci.product_id,
ci.quantite,
ci.prix,
ci.is_reward,
ci.reward_pool_key,
ci.client_username,
ci.client_nom,
ci.client_prenom,
ci.client_telephone,
ci.delivery_address,
ci.status,
ci.created_at,
ci.updated_at,
c.status as command_status,
c.adresse as command_address,
c.total_prix,
c.referral_used,
c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id = ?
ORDER BY ci.id ASC`, commandID).Scan(&rows).Error
if err != nil {
log.Printf("❌ Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération items: %w", err)
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt any
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]any{
"id": row.ID,
"command_id": row.CommandID,
"produit": row.Produit,
"product_id": productIDValue,
"quantite": row.Quantite,
"prix": row.Prix,
"is_reward": row.IsReward,
"reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername,
"client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom,
"client_telephone": row.ClientTelephone,
"delivery_address": ptrStr(row.DeliveryAddress),
"status": ptrStr(row.Status),
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
// Infos commande
"command_status": ptrStr(row.CommandStatus),
"command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix,
"referral_used": row.ReferralUsed,
"livreur_assign": ptrStr(row.LivreurAssign),
"command_created_at": commandCreatedAt,
"category": row.Category,
"unit": row.Unit,
"client_order_number": row.ClientOrderNumber,
}
items = append(items, item)
}
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
return items, nil
}
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
// Retourne map[commandID][]items, même structure que GetCommandItems.
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
result := make(map[int][]map[string]any, len(commandIDs))
if len(commandIDs) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
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"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"`
}
err := d.GDB.Raw(`
SELECT
ci.id, ci.command_id, ci.produit, ci.product_id,
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
c.status as command_status, c.adresse as command_address,
c.total_prix, c.referral_used, c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id IN ?
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
}
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt any
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]any{
"id": row.ID, "command_id": row.CommandID,
"produit": row.Produit, "product_id": productIDValue,
"quantite": row.Quantite, "prix": row.Prix,
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
"category": row.Category, "unit": row.Unit,
"client_order_number": row.ClientOrderNumber,
}
result[row.CommandID] = append(result[row.CommandID], item)
}
return result, 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 {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
if err := validateCommandID(commandID); err != nil {
return err
}
if err := validateItemID(itemID); err != nil {
return err
}
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 {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
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)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return 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
})
}
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
if err := validateItemID(itemID); err != nil {
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
return err
}
if err := validateItemStatus(status); err != nil {
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
return err
}
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; err != nil {
log.Printf("❌ Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification item: %w", err)
}
if !exists {
log.Printf("❌ Item %d non trouvé", itemID)
return fmt.Errorf("item %d non trouvé", itemID)
}
result := d.GDB.Exec(`
UPDATE command_items
SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, status, itemID)
if result.Error != nil {
log.Printf("❌ Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ Item %d non trouvé", itemID)
return fmt.Errorf("item non trouvé")
}
return nil
}
+93
View File
@@ -0,0 +1,93 @@
// ============================================
// db/commands_priority.go
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
// ============================================
package db
import (
"fmt"
"log"
"slices"
)
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]any, error) {
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at
FROM commandes c
WHERE 1=1`
args := []any{}
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
if !slices.Contains(validStatuses, status) {
return nil, fmt.Errorf("statut invalide: %s", status)
}
query += " AND c.status = ?"
args = append(args, status)
}
if username != "" {
query += " AND c.username = ?"
args = append(args, username)
}
query += " ORDER BY c.created_at ASC"
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
}
return commands, nil
}
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
func (d *Database) GetPendingCommandsStats() (map[string]any, error) {
var result struct {
TotalPending int `gorm:"column:total_pending"`
AvgWaitingSeconds *float64 `gorm:"column:avg_waiting_seconds"`
OldestCommandDate *string `gorm:"column:oldest_command_date"`
NewestCommandDate *string `gorm:"column:newest_command_date"`
}
err := d.GDB.Raw(`
SELECT
COUNT(*) as total_pending,
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
MIN(created_at) as oldest_command_date,
MAX(created_at) as newest_command_date
FROM commandes
WHERE status = 'pending'`).Scan(&result).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération stats: %w", err)
}
stats := map[string]any{
"total_pending": result.TotalPending,
"avg_waiting_seconds": 0,
"avg_waiting_minutes": 0,
"oldest_command_date": nil,
"newest_command_date": nil,
"oldest_waiting_minutes": 0,
}
if result.AvgWaitingSeconds != nil {
stats["avg_waiting_seconds"] = int(*result.AvgWaitingSeconds)
stats["avg_waiting_minutes"] = int(*result.AvgWaitingSeconds / 60)
}
if result.OldestCommandDate != nil {
stats["oldest_command_date"] = *result.OldestCommandDate
}
if result.NewestCommandDate != nil {
stats["newest_command_date"] = *result.NewestCommandDate
}
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min",
result.TotalPending, stats["avg_waiting_minutes"])
return stats, nil
}
+982
View File
@@ -0,0 +1,982 @@
package db
import (
"encoding/json"
"errors"
"fmt"
"gestion/models"
"log"
"slices"
"strings"
"time"
"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 {
sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, s)
if len(sanitized) > 1000 {
sanitized = sanitized[:1000]
}
return strings.TrimSpace(sanitized)
}
func validateAddress(address string) error {
address = strings.TrimSpace(address)
if address == "" {
return fmt.Errorf("adresse vide non autorisée")
}
if len(address) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)")
}
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
}
return nil
}
type basketItem struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
PromoDiscount float64 `gorm:"column:promo_discount"`
}
// validateCommandStatus vérifie si le statut est valide
func validateCommandStatus(status string) error {
validStatuses := map[string]bool{
"pending": true,
"assigned": true,
"en_route": true,
"arrived": true,
"livre": true,
"approved": true,
"cancelled": true,
"disabled": true,
}
if !validStatuses[status] {
return fmt.Errorf("statut invalide: %s", status)
}
return nil
}
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
if err := validateUsername(username); err != nil {
return nil, err
}
if err := validateAddress(deliveryAddress); err != nil {
return nil, err
}
client, err := d.GetClientByUsername(username)
if err != nil {
log.Printf("⚠️ Client non trouvé: %v", err)
}
clientNom := ""
clientPrenom := ""
clientTelephone := ""
if client != nil {
clientNom = sanitizeString(client.Nom)
clientPrenom = sanitizeString(client.Prenom)
clientTelephone = sanitizeString(client.Telephone)
}
var (
command *models.Command
totalPrix float64
)
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, promo_discount 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 {
return fmt.Errorf("le panier est vide")
}
for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return fmt.Errorf("données panier invalides")
}
totalPrix += item.Price
}
if totalPrix <= 0 || totalPrix > 100000 {
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
if err := tx.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
return fmt.Errorf("erreur création commande: %w", err)
}
commandID := cmdResult.ID
productIDs2 := make([]int, 0, len(basketItems))
for _, item := range basketItems {
productIDs2 = append(productIDs2, item.ProductID)
}
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)
}
batchItems = append(batchItems, commandItemFull{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantite: item.Quantity,
Prix: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
PromoDiscount: item.PromoDiscount,
ClientUsername: username,
ClientNom: clientNom,
ClientPrenom: clientPrenom,
ClientTelephone: clientTelephone,
DeliveryAddress: deliveryAddress,
Status: "pending",
})
}
if err := tx.Create(&batchItems).Error; err != nil {
return fmt.Errorf("erreur insertion items: %w", err)
}
// 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
}
command = &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Username: username,
Status: "pending",
Total: totalPrix,
DeliveryAddress: deliveryAddress,
CreatedAt: cmdResult.CreatedAt,
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
}
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
var commands []models.Command
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
return commands, err
}
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
if username != "" {
if err := validateUsername(username); err != nil {
return nil, err
}
}
if status != "" {
if err := validateCommandStatus(status); err != nil {
return nil, err
}
}
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
Adresse string `gorm:"column:adresse"`
TotalPrix float64 `gorm:"column:total_prix"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ProposedAddress *string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
ClientOrderNumber int `gorm:"column:client_order_number"`
ReferralUsed float64 `gorm:"column:referral_used"`
CancelReason string `gorm:"column:cancel_reason"`
}
gdb := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at,
c.proposed_address, c.address_proposal_status,
c.client_order_id AS client_order_number,
COALESCE(c.referral_used, 0) AS referral_used,
COALESCE(c.cancel_reason, '') AS cancel_reason`)
if status == "" {
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
} else {
gdb = gdb.Where("c.status = ?", status)
}
if username != "" {
gdb = gdb.Where("c.username = ?", username)
}
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
commands := make([]map[string]any, 0, len(rows))
for _, row := range rows {
command := map[string]any{
"id": row.ID,
"username": row.Username,
"status": row.Status,
"adresse": sanitizeString(row.Adresse),
"total_prix": row.TotalPrix,
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
"address_proposal_status": row.AddressProposalStatus,
"client_order_number": row.ClientOrderNumber,
"referral_used": row.ReferralUsed,
"cancel_reason": row.CancelReason,
}
if row.LivreurAssign != nil {
command["livreur_assign"] = *row.LivreurAssign
} else {
command["livreur_assign"] = nil
}
if row.ProposedAddress != nil {
command["proposed_address"] = *row.ProposedAddress
} else {
command["proposed_address"] = nil
}
commands = append(commands, command)
}
return commands, nil
}
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
}
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
var row struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
Adresse string `gorm:"column:adresse"`
TotalPrix float64 `gorm:"column:total_prix"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ProposedAddress *string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
ReferralUsed float64 `gorm:"column:referral_used"`
ClientOrderNumber int `gorm:"column:client_order_number"`
CancelReason string `gorm:"column:cancel_reason"`
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
if err := d.GDB.Table("commandes c").
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
c.referral_used, c.client_order_id AS client_order_number,
COALESCE(c.cancel_reason, '') AS cancel_reason,
COALESCE(c.dest_latitude, 0) AS dest_latitude,
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
Where("c.id = ?", id).
First(&row).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
}
if row.ID == 0 {
return nil, fmt.Errorf("commande non trouvée")
}
command := map[string]any{
"id": row.ID,
"username": row.Username,
"status": row.Status,
"adresse": row.Adresse,
"total_prix": row.TotalPrix,
"created_at": row.CreatedAt,
"updated_at": row.UpdatedAt,
"address_proposal_status": row.AddressProposalStatus,
"referral_used": row.ReferralUsed,
"client_order_number": row.ClientOrderNumber,
"cancel_reason": row.CancelReason,
"dest_latitude": row.DestLatitude,
"dest_longitude": row.DestLongitude,
}
if row.LivreurAssign != nil {
command["livreur_assign"] = *row.LivreurAssign
} else {
command["livreur_assign"] = nil
}
if row.ProposedAddress != nil {
command["proposed_address"] = *row.ProposedAddress
} else {
command["proposed_address"] = nil
}
return command, nil
}
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.
// Retourne commandID en fallback si introuvable.
func (d *Database) GetClientOrderID(commandID int) int {
var result struct {
ClientOrderID int `gorm:"column:client_order_id"`
}
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
return commandID
}
return result.ClientOrderID
}
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
if len(deliveryAddress) > 500 {
return fmt.Errorf("adresse trop longue (max 500 caractères)")
}
if strings.TrimSpace(deliveryAddress) == "" {
return fmt.Errorf("adresse vide non autorisée")
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"adresse": deliveryAddress,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ Adresse commande %d mise à jour", commandID)
return nil
}
// 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
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil {
return err
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"proposed_address": proposedAddress,
"address_proposal_status": "pending",
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
d.AddCommandLog(commandID, "address_proposed",
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
proposedBy)
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
return nil
}
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
var query string
if accepted {
query = `UPDATE commandes
SET adresse = proposed_address, proposed_address = NULL,
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
} else {
query = `UPDATE commandes
SET proposed_address = NULL, address_proposal_status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
}
result := d.GDB.Exec(query, commandID, clientUsername)
if result.Error != nil {
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("aucune proposition en attente pour cette commande")
}
action := "refusée"
if accepted {
action = "acceptée"
}
d.AddCommandLog(commandID, "address_proposal_"+action,
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
clientUsername)
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
return nil
}
// UpdateCommandStatus met à jour le statut d'une commande
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
if err := validateCommandStatus(status); err != nil {
return err
}
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
"status": status,
"updated_at": time.Now(),
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
return nil
}
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
sanitizedMessage := sanitizeLogMessage(message)
sanitizedAuthor := sanitizeLogMessage(author)
if err := d.GDB.Create(&models.CommandLog{
CommandID: commandID,
Status: status,
Message: sanitizedMessage,
Author: sanitizedAuthor,
}).Error; err != nil {
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
return nil
}
return nil
}
// GetCommandLogs récupère tous les logs d'une commande
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
var rows []models.CommandLog
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
return []map[string]any{}, nil
}
logs := make([]map[string]any, 0, len(rows))
for _, row := range rows {
logs = append(logs, map[string]any{
"id": row.ID,
"command_id": row.CommandID,
"status": row.Status,
"message": row.Message,
"author": row.Author,
"created_at": row.CreatedAt,
})
}
return logs, nil
}
func sanitizeLogMessage(message string) string {
sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, message)
if len(sanitized) > 1000 {
sanitized = sanitized[:1000]
}
return sanitized
}
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
var totalPoints int
var cmdUsernameOut string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
TotalPrix float64 `gorm:"column:total_prix"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
cmd.Status, cmd.Username, cmd.LivreurAssign)
validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
}
if cmd.Status == "approved" {
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
return fmt.Errorf("commande déjà approuvée")
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return fmt.Errorf("commande déjà modifiée par une autre requête")
}
if cmd.Username != "" {
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
if err != nil {
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = points
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, cmd.Username).Error; err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
} else {
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
adminUsername).Error; err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
}
cmdUsernameOut = cmd.Username
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
return 0, err
}
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
commandID, totalPoints)
if livreurAssignOut != "" {
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
}
}()
}
go func() {
commandCacheKey := fmt.Sprintf("command:%d", commandID)
Redis.Del(RedisCtx, commandCacheKey)
if cmdUsernameOut != "" {
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
Redis.Del(RedisCtx, clientCacheKey)
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
Redis.Del(RedisCtx, clientCommandsCacheKey)
}
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
}()
return totalPoints, nil
}
// ApproveDeliveryAtomic - Version atomique pour approbation client
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
var totalPoints int
var pointCategory string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
if cmd.Username != username {
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
username, cmd.Username)
return fmt.Errorf("cette commande ne vous appartient pas")
}
if cmd.Status == "approved" {
log.Printf("️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
return errAlreadyApproved
}
if cmd.Status != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
if result.Error != nil {
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return fmt.Errorf("commande déjà approuvée ou modifiée")
}
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = pts
pointCategory = cat
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
}
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
if errors.Is(err, errAlreadyApproved) {
return 0, "", nil
}
return 0, "", err
}
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
commandID, totalPoints, pointCategory)
if livreurAssignOut != "" {
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
}
}()
}
go func() {
commandCacheKey := fmt.Sprintf("command:%d", commandID)
Redis.Del(RedisCtx, commandCacheKey)
clientCacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, clientCacheKey)
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
Redis.Del(RedisCtx, clientCommandsCacheKey)
log.Printf("✅ [ApproveAtomic] Caches invalidés")
}()
return totalPoints, pointCategory, nil
}
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
var totalPoints int
var pointCategory string
var clientUsernameOut string
var livreurAssignOut string
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmd struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
if err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
return fmt.Errorf("erreur lecture commande: %w", err)
}
if cmd.Username == "" {
return fmt.Errorf("commande non trouvée")
}
// Historiquement restreint à "livre" seul (cf. commentaire de
// 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(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà approuvée ou modifiée")
}
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
if err != nil {
return fmt.Errorf("erreur attribution points: %w", err)
}
totalPoints = pts
pointCategory = cat
if err := tx.Exec(`
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, cmd.Username).Error; err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
staffUsername).Error; err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
}
clientUsernameOut = cmd.Username
livreurAssignOut = cmd.LivreurAssign
return nil
})
if err != nil {
return 0, "", "", err
}
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
commandID, staffUsername, totalPoints, clientUsernameOut)
if livreurAssignOut != "" {
go func() {
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
}
}()
}
go func() {
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
}()
return totalPoints, pointCategory, clientUsernameOut, nil
}
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
}
+184
View File
@@ -0,0 +1,184 @@
package db
import (
"fmt"
"log"
"slices"
"gorm.io/gorm"
)
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]any, error) {
var livreurs []map[string]any
err := d.GDB.Raw(`
SELECT id, username, total, livraison
FROM users
WHERE role = 'livreur'
ORDER BY username`).Scan(&livreurs).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
return livreurs, nil
}
// Admin appelle cette fonction pour assigner un livreur
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
return d.GDB.Transaction(func(tx *gorm.DB) error {
var roleResult struct {
Role string `gorm:"column:role"`
}
err := tx.Raw(`SELECT role FROM users WHERE username = ? FOR UPDATE`, livreurUsername).Scan(&roleResult).Error
if err != nil {
log.Printf("❌ Erreur vérification livreur: %v", err)
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
}
if roleResult.Role == "" {
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
return fmt.Errorf("livreur non trouvé")
}
if roleResult.Role != "livreur" {
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, roleResult.Role)
return fmt.Errorf("l'utilisateur n'est pas un livreur")
}
var cmdResult struct {
Status string `gorm:"column:status"`
LivreurAssign *string `gorm:"column:livreur_assign"`
}
err = tx.Raw(`SELECT status, livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
log.Printf("❌ Erreur vérification commande: %v", err)
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
}
if cmdResult.Status == "" {
log.Printf("❌ Commande %d non trouvée", commandID)
return fmt.Errorf("commande non trouvée")
}
validStatusesForAssignment := []string{"pending", "assigned"}
if !slices.Contains(validStatusesForAssignment, cmdResult.Status) {
log.Printf("❌ Statut invalide pour assignation: %s", cmdResult.Status)
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", cmdResult.Status)
}
result := tx.Exec(`
UPDATE commandes
SET livreur_assign = ?,
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND status IN ('pending', 'assigned')`, livreurUsername, commandID)
if result.Error != nil {
log.Printf("❌ Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ Impossible d'assigner: conditions non remplies")
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "assigned",
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
"admin",
).Error; err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
// Non bloquant
}
return nil
})
}
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
FROM commandes
WHERE livreur_assign = ?`
args := []any{livreurUsername}
if status != "" {
query += " AND status = ?"
args = append(args, status)
} 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"
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
}
return commands, nil
}
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Username string `gorm:"column:username"`
Status string `gorm:"column:status"`
LivreurAssign *string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT username, status, livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
if cmdResult.Username != clientUsername {
return fmt.Errorf("cette commande ne vous appartient pas")
}
if cmdResult.Status != "livre" {
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", cmdResult.Status)
}
result := tx.Exec(`
UPDATE commandes
SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, clientUsername)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
}
if cmdResult.LivreurAssign != nil && *cmdResult.LivreurAssign != "" {
res := tx.Exec(`
UPDATE users
SET livraison = livraison + 1,
total = total + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ? AND role = 'livreur'`, *cmdResult.LivreurAssign)
if res.Error != nil {
log.Printf("⚠️ Erreur incrémentation livreur: %v", res.Error)
} else if res.RowsAffected > 0 {
log.Printf(" ✅ Compteur livreur incrémenté: %s", *cmdResult.LivreurAssign)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "approved",
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
clientUsername,
).Error; err != nil {
log.Printf("⚠️ Erreur ajout log: %v", err)
}
return nil
})
}
+46
View File
@@ -0,0 +1,46 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
q := d.GDB.Order("created_at DESC")
if status != "" {
q = q.Where("status = ?", status)
}
var issues []models.DeliveryIssue
if err := q.Find(&issues).Error; err != nil {
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
}
return issues, nil
}
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
issue := models.DeliveryIssue{
CommandID: commandID,
IssueType: issueType,
Description: description,
Status: "open",
ReportedBy: reportedBy,
}
if err := d.GDB.Create(&issue).Error; err != nil {
return nil, fmt.Errorf("erreur création problème: %w", err)
}
return &issue, nil
}
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
result := d.GDB.Model(&models.DeliveryIssue{}).Where("id = ?", issueID).
Updates(map[string]any{"status": status, "resolution": resolution, "resolved_by": resolvedBy})
if result.Error != nil {
return fmt.Errorf("erreur mise à jour problème: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("problème non trouvé")
}
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
return nil
}
+223
View File
@@ -0,0 +1,223 @@
// ============================================
// db/delivery_management_db.go
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
// ============================================
package db
import (
"fmt"
"log"
"strings"
"time"
)
var allowedStatuses = map[string]bool{
"available": true,
"offline": true,
"busy": true,
}
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
if statuses == "" {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Raw(`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ?`, livreurUsername).Scan(&result).Error
if err != nil {
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
}
return result.Count, nil
}
cleanStatuses, err := ValidateStatuses(statuses)
if err != nil {
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
return 0, err
}
var result struct {
Count int `gorm:"column:count"`
}
err = d.GDB.Raw(
`SELECT COUNT(*) as count FROM commandes WHERE livreur_assign = ? AND status IN ?`,
livreurUsername, cleanStatuses,
).Scan(&result).Error
if err != nil {
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
}
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
result.Count, livreurUsername, cleanStatuses)
return result.Count, nil
}
func ValidateStatuses(statuses string) ([]string, error) {
if statuses == "" {
return nil, nil
}
statusList := strings.Split(statuses, ",")
validStatusMap := map[string]bool{
"pending": true,
"assigned": true,
"en_route": true,
"arrived": true,
"livre": true,
"approved": true,
"cancelled": true,
}
var cleanStatuses []string
var invalidStatuses []string
for _, status := range statusList {
status = strings.TrimSpace(status)
if status == "" {
continue
}
if validStatusMap[status] {
cleanStatuses = append(cleanStatuses, status)
} else {
invalidStatuses = append(invalidStatuses, status)
}
}
if len(invalidStatuses) > 0 {
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
}
if len(cleanStatuses) == 0 {
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
}
return cleanStatuses, nil
}
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
var result struct {
LastDate *time.Time `gorm:"column:last_date"`
}
err := d.GDB.Raw(`
SELECT MAX(updated_at) as last_date
FROM commandes
WHERE livreur_assign = ? AND status = 'approved'`, livreurUsername).Scan(&result).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
}
return result.LastDate, nil
}
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
if err != nil {
return 0, nil
}
var currentID int
_, err = fmt.Sscanf(currentIDStr, "%d", &currentID)
if err != nil {
return 0, nil
}
return currentID, nil
}
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]any, error) {
var history []map[string]any
err := d.GDB.Raw(`
SELECT
c.id as command_id,
c.username as client,
c.status,
c.adresse,
c.total_prix::float8 as total_prix,
c.created_at as assigned_at,
c.updated_at as completed_at
FROM commandes c
WHERE c.livreur_assign = ?
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`, livreurUsername, limit, offset).Scan(&history).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération historique: %w", err)
}
return history, nil
}
// GetDeliveryPersonStatus récupère le statut d'un livreur
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
status, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
return "offline", nil
}
return status, nil
}
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
if !allowedStatuses[status] {
return fmt.Errorf("statut invalide: %s", status)
}
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
if err := Redis.Set(RedisCtx, statusKey, status, 0).Err(); err != nil {
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
return nil
}
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
size, err := Redis.LLen(RedisCtx, queueKey).Result()
if err != nil {
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
}
return int(size), nil
}
// GetDeliverymanQueue récupère la queue complète d'un livreur
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("erreur récupération queue: %w", err)
}
var queue []int
for _, cmdStr := range commands {
var cmdID int
fmt.Sscanf(cmdStr, "%d", &cmdID)
queue = append(queue, cmdID)
}
return queue, nil
}
// UpdateCommandLivreur met à jour le livreur assigné à une commande
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
result := d.GDB.Exec(`
UPDATE commandes
SET livreur_assign = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, livreurUsername, commandID)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour livreur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package db
import (
"fmt"
"log"
)
func (d *Database) GetCommandCategories(commandID int) ([]string, error) {
var categories []string
err := d.GDB.Raw(`
SELECT DISTINCT p.category
FROM command_items ci
JOIN products p ON p.id = ci.product_id
WHERE ci.command_id = ? AND p.category IS NOT NULL AND p.category != ''`,
commandID,
).Scan(&categories).Error
if err != nil {
return nil, fmt.Errorf("erreur lecture catégories commande %d: %w", commandID, err)
}
return categories, nil
}
func (d *Database) GetEligibleDeliverymenForCommand(commandID int) ([]string, error) {
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [DELIVERY_MODE] Erreur lecture settings: %v — fallback single", err)
}
allActive := func() ([]string, error) {
livreurs, err := d.GetAllActiveDeliveryPersons()
if err != nil {
return nil, err
}
names := make([]string, len(livreurs))
for i, l := range livreurs {
names[i] = l.Username
}
return names, nil
}
if settings.DeliveryMode.Mode != "category_based" || len(settings.DeliveryMode.CategoryRoutes) == 0 {
return allActive()
}
categories, err := d.GetCommandCategories(commandID)
if err != nil || len(categories) == 0 {
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: catégories non trouvées — fallback single", commandID)
return allActive()
}
catSet := make(map[string]bool, len(categories))
for _, c := range categories {
catSet[c] = true
}
eligible := make(map[string]bool)
for _, route := range settings.DeliveryMode.CategoryRoutes {
for _, routeCat := range route.Categories {
if catSet[routeCat] {
eligible[route.DeliverymanUsername] = true
break
}
}
}
if len(eligible) == 0 {
log.Printf("⚠️ [DELIVERY_MODE] Cmd %d: aucun livreur pour catégories %v — fallback single", commandID, categories)
return allActive()
}
result := make([]string, 0, len(eligible))
for u := range eligible {
result = append(result, u)
}
log.Printf("🎯 [DELIVERY_MODE] Cmd %d: livreurs éligibles %v (catégories: %v)", commandID, result, categories)
return result, nil
}
+59
View File
@@ -0,0 +1,59 @@
package db
import "gestion/models"
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
// commande de 25g retient +3g, pas +1g).
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
if settings == nil || !settings.FreeGiftsEnabled {
return 0
}
var bestBuy, bestFree float64
found := false
consider := func(tiers []models.FreeGiftTier) {
for _, t := range tiers {
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
continue
}
if !found || t.BuyQuantity > bestBuy {
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
found = true
}
}
}
for _, g := range settings.FreeGifts {
if g.Category != category {
continue
}
if g.AllProducts {
consider(g.Tiers)
continue
}
for _, pq := range g.Products {
if pq.ProductID == productID {
consider(pq.Tiers)
}
}
}
if !found {
return 0
}
return bestFree
}
// ResolveFreeGiftQuantity lit les settings courants et applique
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
// les settings sous la main (même style que ApplyPromotionToPrice).
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
settings, err := d.GetSettings()
if err != nil {
return 0
}
return ResolveFreeGift(&settings, productID, category, quantity)
}
+64
View File
@@ -0,0 +1,64 @@
// ============================================
// db/gps_links.go
// Génération de liens GPS vers différentes plateformes
// ============================================
package db
import (
"fmt"
)
// MapLinks contient les liens vers différentes plateformes de cartographie
type MapLinks struct {
GoogleMaps string `json:"google_maps"`
GoogleMapsApp string `json:"google_maps_app"`
Waze string `json:"waze"`
WazeApp string `json:"waze_app"`
AppleMaps string `json:"apple_maps"`
OpenStreetMap string `json:"openstreetmap"`
BingMaps string `json:"bing_maps"`
HereMaps string `json:"here_maps"`
}
func wazeAppLink(lat, lon float64) string {
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
}
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
return MapLinks{
WazeApp: wazeAppLink(lat, lon),
}
}
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
return wazeAppLink(toLat, toLon)
}
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return nil, fmt.Errorf("position livreur non disponible: %w", err)
}
command, err := d.GetCommandByID(commandID)
if err != nil {
return nil, fmt.Errorf("commande non trouvée: %w", err)
}
var destLat, destLon float64
if v, ok := command["dest_latitude"].(float64); ok {
destLat = v
}
if v, ok := command["dest_longitude"].(float64); ok {
destLon = v
}
if destLat == 0 && destLon == 0 {
return nil, fmt.Errorf("coordonnées destination invalides")
}
return map[string]string{
"waze_app": wazeAppLink(destLat, destLon),
}, nil
}
+37
View File
@@ -0,0 +1,37 @@
// ============================================
// db/commands_history.go
// ============================================
// Fonctions pour l'historique des commandes terminées
package db
import (
"fmt"
"log"
)
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]any, error) {
query := `
SELECT
id,
client_order_id AS client_order_number,
username,
status,
adresse,
total_prix::float8 as total_prix,
livreur_assign,
created_at,
updated_at
FROM commandes
WHERE username = ? AND status = 'approved'
ORDER BY created_at DESC
`
var commands []map[string]any
if err := d.GDB.Raw(query, username).Scan(&commands).Error; err != nil {
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
}
return commands, nil
}
+640
View File
@@ -0,0 +1,640 @@
package db
import (
"database/sql"
"fmt"
"log"
"os"
"time"
_ "github.com/lib/pq"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// ============================================
// STRUCTURES
// ============================================
// Database encapsule la connexion à la base de données
type Database struct {
*sql.DB
GDB *gorm.DB
}
// DB est l'instance globale de la base de données
var DB *Database
// ============================================
// INITIALISATION DE LA BASE DE DONNÉES
// ============================================
// InitDB initialise la connexion à PostgreSQL et crée les tables
func InitDB() *Database {
// Récupérer les paramètres de connexion
host := getEnv("DB_HOST", "localhost")
port := getEnv("DB_PORT", "5432")
user := getEnv("DB_USER", "postgres")
password := getEnv("DB_PASSWORD", "postgres")
dbname := getEnv("DB_NAME", "gestion_db")
sslmode := getEnv("DB_SSLMODE", "disable")
// Construire la chaîne de connexion
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode)
// Ouvrir la connexion
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
}
// Configuration du pool de connexions
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
// Tester la connexion
if err = db.Ping(); err != nil {
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
}
log.Println("✅ Connexion à PostgreSQL établie avec succès")
// Initialiser GORM en réutilisant la connexion sql.DB existante
gormDB, err := gorm.Open(postgres.New(postgres.Config{
Conn: db,
}), &gorm.Config{
SkipDefaultTransaction: true,
PrepareStmt: true,
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
}
// Créer l'instance Database
database := &Database{db, gormDB}
// Assigner à la variable globale
DB = database
// Créer les tables
if err = database.createTables(); err != nil {
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
}
log.Println("✅ Tables créées avec succès")
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
}
// Migration: proposition de modification d'adresse par admin/cabine
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
}
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
log.Fatalf("❌ Erreur migration unit products: %v", err)
}
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
if _, err = database.Exec(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'baskets' AND column_name = 'quantity'
AND data_type = 'integer'
) THEN
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
END IF;
END
$$;
`); err != nil {
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
}
// Migration: 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: baskets.promo_discount + command_items.promo_discount —
// montant (en €) économisé par une promotion de prix sur cette ligne,
// capturé une fois pour toutes au moment de AddToBasket (voir
// db_basket.go) puis copié tel quel au checkout, pour permettre des
// statistiques historiques fiables même si la config de promo change
// ensuite (contrairement à un recalcul a posteriori sur les settings
// courants, qui donnerait un résultat faux pour les anciennes commandes).
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err)
}
if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'command_items' AND column_name = 'quantite'
AND data_type = 'integer'
) THEN
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
END IF;
END
$$;
`); err != nil {
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
}
// Migration: ajouter colonne color pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
log.Fatalf("❌ Erreur migration categories.color: %v", err)
}
// Migration: ajouter colonne is_coming_soon pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
}
// Migration: 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
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
)`); err != nil {
log.Fatalf("❌ Erreur migration app_settings: %v", err)
}
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
}
// Migration: montant de parrainage utilisé pour la commande
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
}
// Migration: méthode de paiement (cash par défaut, crypto si paiement NowPayments)
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS payment_method VARCHAR(20) NOT NULL DEFAULT 'cash'`); err != nil {
log.Fatalf("❌ Erreur migration commandes.payment_method: %v", err)
}
// Migration: identifiant de commande par client (numérotation indépendante par client, commence à 1)
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS client_order_id INTEGER NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.client_order_id: %v", err)
}
// Backfill: numéroter les commandes existantes par client dans l'ordre d'insertion
if _, err = database.Exec(`
UPDATE commandes c
SET client_order_id = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY username ORDER BY id) AS rn
FROM commandes
) sub
WHERE c.id = sub.id AND c.client_order_id = 0
`); err != nil {
log.Fatalf("❌ Erreur backfill commandes.client_order_id: %v", err)
}
// Migration: raison d'annulation par le client
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS cancel_reason TEXT`); err != nil {
log.Fatalf("❌ Erreur migration commandes.cancel_reason: %v", err)
}
// Migration: coordonnées GPS de destination et du livreur
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_latitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.dest_latitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS dest_longitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.dest_longitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_latitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.livreur_latitude: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS livreur_longitude DOUBLE PRECISION`); err != nil {
log.Fatalf("❌ Erreur migration commandes.livreur_longitude: %v", err)
}
// Migration: table de suivi des paiements crypto
if _, err = database.Exec(`
CREATE TABLE IF NOT EXISTS crypto_payments (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
nowpayment_id TEXT NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
price_amount NUMERIC(10,2) NOT NULL,
price_currency VARCHAR(10) NOT NULL DEFAULT 'eur',
pay_currency VARCHAR(20) NOT NULL,
pay_address TEXT NOT NULL,
pay_amount NUMERIC(20,8) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
log.Fatalf("❌ Erreur migration crypto_payments: %v", err)
}
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_command_id ON crypto_payments(command_id)`); err != nil {
log.Fatalf("❌ Erreur index crypto_payments.command_id: %v", err)
}
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_crypto_payments_nowpayment_id ON crypto_payments(nowpayment_id)`); err != nil {
log.Fatalf("❌ Erreur index crypto_payments.nowpayment_id: %v", err)
}
// Migration: points extra pour tous les pools de points (stockage dynamique par clé)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS points_extra JSONB NOT NULL DEFAULT '{}'::jsonb`); err != nil {
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
}
// 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
go database.cleanExpiredTokensPeriodically()
return database
}
// ============================================
// CRÉATION DES TABLES
// ============================================
// createTables crée toutes les tables nécessaires
func (db *Database) createTables() error {
queries := []string{
// ============================
// TABLE users
// ============================
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'user',
total NUMERIC(10,2) DEFAULT 0.0,
livraison NUMERIC(10,2) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
// ============================
`CREATE TABLE IF NOT EXISTS clients (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
nom VARCHAR(100) NOT NULL,
prenom VARCHAR(100) NOT NULL,
telephone VARCHAR(20) NOT NULL UNIQUE,
command INTEGER DEFAULT 0,
amende NUMERIC(10,2) DEFAULT 0.0,
cancel_commande INTEGER DEFAULT 0,
cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0,
parrain VARCHAR(255) DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE jwt_tokens
// ============================
`CREATE TABLE IF NOT EXISTS jwt_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
token TEXT NOT NULL,
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_fin TIMESTAMP NOT NULL,
CHECK (date_fin > date_save)
);`,
// ============================
// TABLE categories
// ============================
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE products
// ============================
`CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
unit VARCHAR(10) NOT NULL DEFAULT 'u',
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE product_prices
// ============================
`CREATE TABLE IF NOT EXISTS product_prices (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity NUMERIC(10,3) NOT NULL,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, quantity)
);`,
// ============================
// TABLE media
// ============================
`CREATE TABLE IF NOT EXISTS media (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL,
type VARCHAR(50) NOT NULL,
key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE commandes
// ============================
`CREATE TABLE IF NOT EXISTS commandes (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
livreur_assign VARCHAR(50),
adresse TEXT DEFAULT 'Adresse non spécifiée',
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE command_items
// ============================
`CREATE TABLE IF NOT EXISTS command_items (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
produit VARCHAR(255) NOT NULL,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
quantite INTEGER NOT NULL,
prix NUMERIC(10,2) NOT NULL,
-- Nouvelles colonnes pour les infos client
client_username VARCHAR(255),
client_nom VARCHAR(100),
client_prenom VARCHAR(100),
client_telephone VARCHAR(20),
delivery_address TEXT,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE baskets
// ============================
`CREATE TABLE IF NOT EXISTS baskets (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
quantity NUMERIC(10,3) NOT NULL,
price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE command_logs
// ============================
`CREATE TABLE IF NOT EXISTS command_logs (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
status VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
author VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE delivery_issues
// ============================
`CREATE TABLE IF NOT EXISTS delivery_issues (
id SERIAL PRIMARY KEY,
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
issue_type VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
reported_by VARCHAR(100) NOT NULL,
resolved_by VARCHAR(100),
resolution TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS alerte_policy (
id SERIAL PRIMARY KEY,
username VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'false',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS adresse_correction (
id SERIAL PRIMARY KEY,
invalid_address VARCHAR(255) NOT NULL UNIQUE,
correct_address VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
// ============================
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
// ============================
// 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 {
if _, err := db.Exec(query); err != nil {
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
}
}
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
return nil
}
// ============================================
// MÉTHODES POUR LES TOKENS JWT
// ============================================
// CleanExpiredTokens supprime les tokens JWT expirés
func (d *Database) CleanExpiredTokens() error {
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
result, err := d.Exec(query, time.Now())
if err != nil {
return err
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
}
return nil
}
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
func (db *Database) cleanExpiredTokensPeriodically() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if err := db.CleanExpiredTokens(); err != nil {
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
}
}
}
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+127
View File
@@ -0,0 +1,127 @@
package db
import (
"fmt"
"log"
"time"
"gorm.io/gorm"
)
type jwtToken struct {
ID int `gorm:"primaryKey;autoIncrement"`
UserID int `gorm:"column:user_id;index"`
UserType string `gorm:"column:user_type"`
Token string `gorm:"column:token;uniqueIndex"`
DateSave time.Time `gorm:"column:date_save;autoCreateTime"`
DateFin time.Time `gorm:"column:date_fin"`
}
func (jwtToken) TableName() string { return "jwt_tokens" }
var validTokenTypes = map[string]bool{
"client": true, "admin": true, "cabine": true, "livreur": true,
}
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
if !validTokenTypes[userType] {
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
}
t := jwtToken{UserID: userID, UserType: userType, Token: token, DateFin: expiresAt}
if err := d.GDB.Create(&t).Error; err != nil {
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
}
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
return nil
}
func (d *Database) IsTokenValid(token string) (bool, error) {
var count int64
err := d.GDB.Model(&jwtToken{}).
Where("token = ? AND date_fin > ?", token, time.Now()).
Count(&count).Error
return count > 0, err
}
func (d *Database) RevokeToken(token string) error {
result := d.GDB.Where("token = ?", token).Delete(&jwtToken{})
if result.Error != nil {
return fmt.Errorf("erreur lors de la révocation du token: %w", result.Error)
}
if result.RowsAffected > 0 {
log.Printf("✅ Token révoqué avec succès")
}
return nil
}
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
result := d.GDB.Where("user_id = ? AND user_type = ?", userID, userType).Delete(&jwtToken{})
if result.Error != nil {
return fmt.Errorf("erreur lors de la révocation des tokens: %w", result.Error)
}
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", result.RowsAffected, userType, userID)
return nil
}
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]any, error) {
var tokens []jwtToken
err := d.GDB.Where("user_id = ? AND user_type = ? AND date_fin > ?", userID, userType, time.Now()).
Order("date_save DESC").Find(&tokens).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
}
result := make([]map[string]any, len(tokens))
for i, t := range tokens {
truncated := t.Token
if len(truncated) > 20 {
truncated = truncated[:20] + "..."
}
result[i] = map[string]any{
"id": t.ID,
"token": truncated,
"date_save": t.DateSave,
"date_fin": t.DateFin,
"user_type": t.UserType,
}
}
return result, nil
}
func (d *Database) GetTokenInfo(token string) (map[string]any, error) {
var t jwtToken
err := d.GDB.Where("token = ? AND date_fin > ?", token, time.Now()).First(&t).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("token non trouvé ou expiré")
}
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
}
return map[string]any{
"user_id": t.UserID,
"user_type": t.UserType,
"date_save": t.DateSave,
"date_fin": t.DateFin,
}, nil
}
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
type row struct {
UserType string
Count int
}
var rows []row
err := d.GDB.Model(&jwtToken{}).
Select("user_type, COUNT(*) as count").
Where("date_fin > ?", time.Now()).
Group("user_type").
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
}
counts := make(map[string]int, len(rows))
for _, r := range rows {
counts[r.UserType] = r.Count
}
return counts, nil
}
+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
}
+314
View File
@@ -0,0 +1,314 @@
package db
import (
"fmt"
"gestion/models"
"log"
"slices"
"strings"
"time"
)
type MediaInterface interface {
GetProductID() int
GetType() string
GetURL() string
SetID(int)
}
// validateMediaID vérifie la validité d'un ID média
func validateMediaID(mediaID int) error {
if mediaID <= 0 {
return fmt.Errorf("ID média invalide: %d", mediaID)
}
if mediaID > 2147483647 { // Max int32
return fmt.Errorf("ID média trop grand")
}
return nil
}
// validateProductID vérifie la validité d'un ID produit
func validateProductID(productID int) error {
if productID <= 0 {
return fmt.Errorf("ID produit invalide: %d", productID)
}
if productID > 2147483647 {
return fmt.Errorf("ID produit trop grand")
}
return nil
}
// validateMediaType vérifie le type de média
func validateMediaType(mediaType string) error {
validTypes := []string{"image", "video"}
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
if slices.Contains(validTypes, mediaType) {
return nil
}
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
}
// validateMediaURL vérifie la sécurité de l'URL
func validateMediaURL(url string) error {
if len(url) == 0 {
return fmt.Errorf("URL vide")
}
if len(url) > 500 {
return fmt.Errorf("URL trop longue (max 500 caractères)")
}
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
return fmt.Errorf("path traversal détecté dans l'URL")
}
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
}
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
for _, char := range dangerousChars {
if strings.Contains(url, char) {
return fmt.Errorf("caractères interdits dans l'URL")
}
}
return nil
}
func (d *Database) CreateMedia(media any) error {
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
m, ok := media.(MediaInterface)
if !ok {
if mediaPtr, isPtr := media.(*models.Media); isPtr {
m = mediaPtr
ok = true
} else {
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
return fmt.Errorf("type de média invalide: reçu %T", media)
}
}
if !ok {
return fmt.Errorf("type de média invalide")
}
productID := m.GetProductID()
mediaType := m.GetType()
mediaURL := m.GetURL()
mediaKey := ""
if mediaPtr, isPtr := media.(*models.Media); isPtr {
mediaKey = mediaPtr.Key
}
if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := validateMediaType(mediaType); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := validateMediaURL(mediaURL); err != nil {
log.Printf("❌ [CreateMedia] %v", err)
return err
}
if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
log.Printf("❌ [InsertMedia] %v", err)
return err
}
return nil
}
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
return fmt.Errorf("erreur vérification produit: %w", err)
}
if !exists {
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
return fmt.Errorf("produit %d n'existe pas", productID)
}
var result struct {
ID int `gorm:"column:id"`
}
err := d.GDB.Raw(`
INSERT INTO media (product_id, url, type, key, created_at)
VALUES (?, ?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, key, time.Now(),
).Scan(&result).Error
if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
return fmt.Errorf("erreur création média: %w", err)
}
if mi, ok := m.(MediaInterface); ok {
mi.SetID(result.ID)
}
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", result.ID, mediaType)
return nil
}
func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [GetMediaByID] %v", err)
return nil, err
}
var media models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, key, created_at
FROM media WHERE id = ?`, mediaID).Scan(&media).Error
if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération média: %w", err)
}
if media.ID == 0 {
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
return nil, fmt.Errorf("média non trouvé")
}
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
return &media, nil
}
// 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) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
if err := validateProductID(productID); err != nil {
log.Printf("❌ [GetMediaByProductID] %v", err)
return nil, err
}
var mediaList []models.Media
err := d.GDB.Raw(`
SELECT id, product_id, url, type, key, created_at
FROM media WHERE product_id = ?
ORDER BY id ASC`, productID).Scan(&mediaList).Error
if err != nil {
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
return nil, fmt.Errorf("erreur récupération médias: %w", err)
}
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
return mediaList, nil
}
func (d *Database) UpdateMedia(media *models.Media) error {
if media == nil {
return fmt.Errorf("média nil")
}
if err := validateMediaID(media.ID); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
if err := validateMediaType(media.Type); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
if err := validateMediaURL(media.URL); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
if err := d.CheckMediaExists(media); err != nil {
log.Printf("❌ [UpdateMedia] %v", err)
return err
}
result := d.GDB.Exec(`UPDATE media SET url = ?, type = ? WHERE id = ?`, media.URL, media.Type, media.ID)
if result.Error != nil {
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur mise à jour média: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
return fmt.Errorf("média non trouvé")
}
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
return nil
}
func (d *Database) CheckMediaExists(media *models.Media) error {
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)`, media.ID).Scan(&exists).Error; err != nil {
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification média: %w", err)
}
if !exists {
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
return fmt.Errorf("média %d non trouvé", media.ID)
}
return nil
}
func (d *Database) DeleteMedia(mediaID int) error {
if err := validateMediaID(mediaID); err != nil {
log.Printf("❌ [DeleteMedia] %v", err)
return err
}
media := models.Media{ID: mediaID}
if err := d.CheckMediaExists(&media); err != nil {
log.Printf("❌ [DeleteMedia] %v", err)
return err
}
result := d.GDB.Exec(`DELETE FROM media WHERE id = ?`, mediaID)
if result.Error != nil {
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", result.Error)
return fmt.Errorf("erreur suppression média: %w", result.Error)
}
if result.RowsAffected == 0 {
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
return fmt.Errorf("média non trouvé")
}
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
return nil
}
func (d *Database) DeleteMediaByProductID(productID int) error {
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
if err := validateProductID(productID); err != nil {
log.Printf("❌ [DeleteMediaByProductID] %v", err)
return err
}
var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
return fmt.Errorf("erreur vérification produit: %w", err)
}
if !exists {
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
return fmt.Errorf("produit %d non trouvé", productID)
}
result := d.GDB.Exec(`DELETE FROM media WHERE product_id = ?`, productID)
if result.Error != nil {
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", result.Error)
return fmt.Errorf("erreur suppression médias: %w", result.Error)
}
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
result.RowsAffected, productID)
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"gestion/services"
"log"
"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 {
notifKey := fmt.Sprintf("notifications:%s", username)
notification := map[string]any{
"command_id": commandID,
"type": notifType,
"message": message,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
pipe := Redis.Pipeline()
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.IsNotificationsEnabled() {
if chatID, ok, err := d.GetClientTelegramChatID(username); err == nil && ok {
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)
}
}
}
log.Printf("📬 Notification envoyée à %s: %s", username, message)
return nil
}
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username)
notification := map[string]any{
"command_id": commandID,
"type": notifType,
"message": message,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
pipe2 := Redis.Pipeline()
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.IsNotificationsEnabled() {
if chatID, ok, err := d.GetUserTelegramChatID(username); err == nil && ok {
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)
}
}
}
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
return nil
}
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
var users []struct {
Username string `gorm:"column:username"`
}
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
notification := map[string]any{
"command_id": commandID,
"type": "new_order",
"message": msg,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
pipe := Redis.Pipeline()
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
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 {
capturedChatID := chatID
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🔔 <b>Nouvelle commande</b>\n\n%s", msg))
}
}
}
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
}
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
var users []struct {
Username string `gorm:"column:username"`
}
if err := d.GDB.Model(&models.User{}).Select("username").Where("role IN ?", []string{"admin", "cabine"}).Scan(&users).Error; err != nil {
log.Printf("❌ [ALERT_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
body := fmt.Sprintf("%s — livreur : %s", alertMessage, livreurUsername)
notification := map[string]any{
"alert_id": alertID,
"type": "alert",
"message": body,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
pipe := Redis.Pipeline()
for _, u := range users {
notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
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 {
capturedChatID := chatID
go sendTelegramNotif(capturedChatID, fmt.Sprintf("🚨 <b>Alerte livreur</b>\n\n%s", body))
}
}
}
log.Printf("🚨 [ALERT_NOTIF] Notif Redis (%d users) pour alerte #%d de %s", count, alertID, livreurUsername)
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"database/sql"
"fmt"
"gestion/models"
"gorm.io/gorm"
)
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
result := d.GDB.Model(&models.Client{}).
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
Update("parrain", parrainUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client introuvable ou parrain déjà défini")
}
return nil
}
// 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) {
var parrain sql.NullString
err := d.GDB.Table("clients").
Select("parrain").
Where("username = ?", clientUsername).
Scan(&parrain).Error
if err != nil {
return "", err
}
return parrain.String, nil
}
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
var clients []models.Client
err := d.GDB.
Where("parrain = ?", parrainUsername).
Find(&clients).Error
return clients, err
}
func (d *Database) GetParrainStats() ([]map[string]any, error) {
var rows []struct {
Parrain string `gorm:"column:parrain"`
FilleulCount int `gorm:"column:filleul_count"`
ReferralBalance float64 `gorm:"column:referral_balance"`
}
err := d.GDB.Raw(`
SELECT
c.parrain,
COUNT(c.username) AS filleul_count,
p.referral_balance
FROM clients c
JOIN clients p ON p.username = c.parrain
WHERE c.parrain IS NOT NULL AND c.parrain <> ''
GROUP BY c.parrain, p.referral_balance
ORDER BY filleul_count DESC
`).Scan(&rows).Error
if err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(rows))
for _, r := range rows {
result = append(result, map[string]any{
"parrain": r.Parrain,
"filleul_count": r.FilleulCount,
"referral_balance": r.ReferralBalance,
})
}
return result, nil
}
+95
View File
@@ -0,0 +1,95 @@
package db
import (
"fmt"
"gestion/models"
"gorm.io/gorm"
)
func (d *Database) CreateCryptoPayment(commandID int, nowPaymentID, status, priceCurrency, payCurrency, payAddress string, priceAmount, payAmount float64) (*models.CryptoPayment, error) {
p := models.CryptoPayment{
CommandID: commandID,
NowPaymentID: nowPaymentID,
Status: status,
PriceAmount: priceAmount,
PriceCurrency: priceCurrency,
PayCurrency: payCurrency,
PayAddress: payAddress,
PayAmount: payAmount,
}
if err := d.GDB.Create(&p).Error; err != nil {
return nil, fmt.Errorf("CreateCryptoPayment: %w", err)
}
return &p, nil
}
func (d *Database) GetCryptoPaymentByCommandID(commandID int) (*models.CryptoPayment, error) {
var p models.CryptoPayment
err := d.GDB.Where("command_id = ?", commandID).Order("created_at DESC").First(&p).Error
if err != nil {
if isNotFound(err) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (d *Database) GetCryptoPaymentByNowPaymentID(nowPaymentID string) (*models.CryptoPayment, error) {
var p models.CryptoPayment
err := d.GDB.Where("nowpayment_id = ?", nowPaymentID).First(&p).Error
if err != nil {
if isNotFound(err) {
return nil, nil
}
return nil, err
}
return &p, nil
}
func (d *Database) GetPendingCryptoPayments() ([]models.CryptoPayment, error) {
var payments []models.CryptoPayment
err := d.GDB.Where("status NOT IN ?", []string{"finished", "failed", "expired", "refunded"}).
Order("created_at ASC").Find(&payments).Error
return payments, err
}
func (d *Database) UpdateCryptoPaymentStatus(id int, status string, payAmount float64) error {
return d.GDB.Model(&models.CryptoPayment{}).Where("id = ?", id).
Updates(map[string]any{"status": status, "pay_amount": payAmount}).Error
}
func (d *Database) ActivateCryptoCommand(commandID int) error {
return d.GDB.Exec(`UPDATE commandes SET status = 'pending', updated_at = NOW() WHERE id = ? AND status = 'pending_payment'`, commandID).Error
}
func (d *Database) CancelCryptoCommand(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
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 {
ProductID int
Quantite float64
}
var items []item
if err := tx.Raw(`SELECT product_id, quantite FROM command_items WHERE command_id = ?`, commandID).Scan(&items).Error; err != nil {
return err
}
for _, it := range items {
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`, it.Quantite, it.ProductID).Error; err != nil {
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 = ?`, commandID).Error
})
}
+328
View File
@@ -0,0 +1,328 @@
package db
import (
"fmt"
"gestion/models"
"log"
"time"
"gorm.io/gorm"
)
// CreateProduct crée un nouveau produit avec ses prix
func (d *Database) CreateProduct(product any) error {
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
type ProductInterface interface {
GetName() string
GetCategory() string
GetDescription() string
GetStock() float64
GetUnit() string
GetPrices() []models.ProductPrice
SetID(int)
SetCreatedAt(time.Time)
SetUpdatedAt(time.Time)
}
p, ok := product.(ProductInterface)
if !ok {
if prodPtr, isPtr := product.(*models.Product); isPtr {
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
p = prodPtr
ok = true
} else {
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
}
}
if ok {
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit())
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
}
now := time.Now()
var result struct {
ID int `gorm:"column:id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
comingSoonVal := false
if prodModel, ok2 := product.(*models.Product); ok2 {
comingSoonVal = prodModel.ComingSoon
}
err := d.GDB.Raw(`
INSERT INTO products (name, category, description, stock, unit, coming_soon, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at, updated_at`,
p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), comingSoonVal, now, now,
).Scan(&result).Error
if err != nil {
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
return fmt.Errorf("erreur création produit: %v", err)
}
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", result.ID)
p.SetID(result.ID)
p.SetCreatedAt(result.CreatedAt)
p.SetUpdatedAt(result.UpdatedAt)
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
priceRows := make([]models.ProductPrice, len(rawPrices))
for i, price := range rawPrices {
priceRows[i] = models.ProductPrice{
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)
}
log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
}
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
return nil
}
// GetProductByID récupère un produit par son ID avec prices ET stock
func (d *Database) GetProductByID(id int) (models.Product, error) {
log.Printf("📦 [GetProductByID] START - ID=%d", id)
var p models.Product
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products
WHERE id = ?`, id).Scan(&p).Error
if err != nil {
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
return p, err
}
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
prices, err := d.GetProductPrices(p.ID)
if err != nil {
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
p.Prices = []models.ProductPrice{}
} else {
p.Prices = prices
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
}
return p, nil
}
// 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) {
log.Println("📦 [GetAllProducts] START")
var products []models.Product
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products
ORDER BY id ASC`).Scan(&products).Error
if err != nil {
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
return nil, err
}
productIDs := make([]int, len(products))
for i, p := range products {
productIDs[i] = p.ID
}
allPrices := d.GetProductPricesBatch(productIDs)
allMedia := d.GetMediaBatch(productIDs)
for i := range products {
if prices, ok := allPrices[products[i].ID]; ok {
products[i].Prices = prices
} else {
products[i].Prices = []models.ProductPrice{}
}
if media, ok := allMedia[products[i].ID]; ok {
products[i].Media = media
} else {
products[i].Media = []models.Media{}
}
}
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
return products, nil
}
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
func (d *Database) GetProductsByCategory(category string) ([]models.Product, error) {
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
var products []models.Product
err := d.GDB.Raw(`
SELECT id, name, category, description, stock, unit, coming_soon, created_at, updated_at
FROM products
WHERE category = ?
ORDER BY created_at DESC`, category).Scan(&products).Error
if err != nil {
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
}
catProductIDs := make([]int, len(products))
for i, p := range products {
catProductIDs[i] = p.ID
}
catPrices := d.GetProductPricesBatch(catProductIDs)
for i := range products {
if prices, ok := catPrices[products[i].ID]; ok {
products[i].Prices = prices
} else {
products[i].Prices = []models.ProductPrice{}
}
}
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
return products, nil
}
func (d *Database) UpdateProduct(productID int, name, category, description, unit string, comingSoon bool, prices []models.ProductPrice) error {
err := d.GDB.Exec(`
UPDATE products
SET name = ?, category = ?, description = ?, unit = ?, coming_soon = ?, updated_at = ?
WHERE id = ?`,
name, category, description, unit, comingSoon, time.Now(), productID).Error
if err != nil {
return fmt.Errorf("erreur mise à jour produit: %w", err)
}
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
if len(prices) > 0 {
priceRows := make([]models.ProductPrice, len(prices))
for i, price := range prices {
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
}
// 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
func (d *Database) DeleteProduct(productID int) error {
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
if result.Error != nil {
return fmt.Errorf("erreur suppression produit: %v", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("produit introuvable")
}
return nil
}
func (d *Database) GetProductNameByID(productID int) (string, error) {
var result struct {
Name string `gorm:"column:name"`
}
err := d.GDB.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&result).Error
if err != nil {
return "", err
}
if result.Name == "" {
return "", fmt.Errorf("produit non trouvé")
}
return result.Name, nil
}
+56
View File
@@ -0,0 +1,56 @@
package db
import (
"fmt"
"gestion/models"
)
func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
var prices []models.ProductPrice
if err := d.GDB.Where("product_id = ?", productID).Order("quantity ASC").Find(&prices).Error; err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
return prices, nil
}
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
result := make(map[int][]models.ProductPrice, len(productIDs))
if len(productIDs) == 0 {
return result
}
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) AddActivePrice(priceID int) error {
result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID).
Update("active_price", true)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
func (d *Database) DeActivePrice(priceID int) error {
result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID).
Update("active_price", false)
if result.Error != nil {
return fmt.Errorf("erreur lors de l'activation du prix: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("prix introuvable")
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package db
import (
"gestion/models"
"math"
)
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
// configurée dans les settings couvre exactement ce couple (produit,
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
// en jeu : la promotion s'applique à toute commande de cette quantité.
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
if settings == nil || !settings.PromotionsEnabled {
return 0, false
}
for _, promo := range settings.Promotions {
if promo.Category != category || promo.DiscountPercent <= 0 {
continue
}
if promo.AllProducts {
if promo.Quantity == quantity {
return promo.DiscountPercent, true
}
continue
}
for _, pq := range promo.Products {
if pq.ProductID == productID && pq.Quantity == quantity {
return promo.DiscountPercent, true
}
}
}
return 0, false
}
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
settings, err := d.GetSettings()
if err != nil {
return price, false
}
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
if !ok {
return price, false
}
return math.Round(price*(1-discount/100)*100) / 100, true
}
+219
View File
@@ -0,0 +1,219 @@
// ============================================
// db/queue_auto_next_db.go
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
// ============================================
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"slices"
)
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
// Récupérer la prochaine commande dans sa queue
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
// Pas de commande dans la queue personnelle
log.Printf("️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
// Mettre le livreur en "available"
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
// ✅ Mettre à jour le statut basé sur la queue
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
continue
}
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
// Récupérer les détails de la commande depuis Redis
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue // Essayer la suivante
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue
}
// ✅ Vérifier le statut de la commande dans la DB
command, err := d.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue // Essayer la suivante
}
currentStatus, _ := command["status"].(string)
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
if slices.Contains(nonAssignableStatuses, currentStatus) {
d.RemoveCommandFromAllQueues(commandID, deliveryman)
continue
}
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
d.AddCommandLog(commandID, "next_in_queue",
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
"system")
return nil
}
// Mettre le livreur en available
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := fmt.Sprintf("%d", commandID)
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
// Retirer de la queue
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
return err
}
if result == 0 {
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
} else {
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
}
Redis.Del(RedisCtx, commandKey)
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
return nil
}
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
if err != nil {
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
}
// 2. Supprimer les caches associés
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
Redis.Del(RedisCtx, destCacheKey)
etaKey := fmt.Sprintf("command:eta:%d", commandID)
Redis.Del(RedisCtx, etaKey)
// 3. Supprimer la clé de données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
Redis.Del(RedisCtx, commandKey)
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
// 4. Traiter la prochaine commande
return d.ProcessNextCommandForDeliveryman(deliveryman)
}
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
commandIDStr := fmt.Sprintf("%d", commandID)
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
// 1. Queue du livreur spécifique
if deliveryman != "" {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
} else if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
} else {
log.Printf("️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
}
}
// 2. Queue générale
removed := false
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
removed = true
}
// 3. Queue prioritaire
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
removed = true
}
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
if result > 0 {
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
removed = true
}
}
// 5. Supprimer la clé de données
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
result, _ = Redis.Del(RedisCtx, commandKey).Result()
if result > 0 {
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
removed = true
}
if !removed {
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
} else {
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package db
import (
"fmt"
"gestion/models"
"gorm.io/gorm"
)
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
var balance float64
err := d.GDB.Table("clients").Select("referral_balance").Where("username = ?", username).Scan(&balance).Error
if err != nil {
return 0, err
}
return balance, nil
}
func (d *Database) CreditClientReferral(username string, amount float64) error {
if amount <= 0 {
return fmt.Errorf("le montant doit être positif")
}
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
"referral_balance": gorm.Expr("referral_balance + ?", amount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
func (d *Database) DebitReferralBalance(username string, amount float64) error {
if amount <= 0 {
return nil
}
return d.GDB.Transaction(func(tx *gorm.DB) error {
var balance float64
if err := tx.Raw(`SELECT referral_balance FROM clients WHERE username = ? FOR UPDATE`, username).Scan(&balance).Error; err != nil {
return fmt.Errorf("client non trouvé")
}
if balance < amount {
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
}
return tx.Exec(`UPDATE clients SET referral_balance = referral_balance - ? WHERE username = ?`, amount, username).Error
})
}
func (d *Database) ResetClientReferralBalance(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("referral_balance", 0)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
// ============================================
// db/cancel_sanctions_db.go
// GESTION DES SANCTIONS ÉVOLUTIVES
// ============================================
package db
import (
"fmt"
"gestion/models"
"log"
"sort"
"gorm.io/gorm"
)
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
var result struct {
Count int `gorm:"column:count"`
}
err := d.GDB.Table("clients").Select("COALESCE(cancellations_count, 0) as count").Where("username = ?", username).Scan(&result).Error
if err != nil {
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
}
return result.Count, nil
}
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
if len(tiers) == 0 {
return 0
}
sorted := make([]models.PenaltyTier, len(tiers))
copy(sorted, tiers)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].MinCancel > sorted[j].MinCancel
})
for _, t := range sorted {
if count >= t.MinCancel {
return t.Amount
}
}
return sorted[len(sorted)-1].Amount
}
// 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é
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return 0, err
}
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty)
return penalty, nil
}
// 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) {
tiers := d.penaltyTiers("ApplyCancellationPenalty")
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)
result := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username)
if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur application pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
return nil
})
if err != nil {
return 0, err
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return penalty, nil
}
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
func (d *Database) GetClientCancellationHistory(username string) (map[string]any, error) {
nextPenalty, err := d.CalculateCancellationPenalty(username)
if err != nil {
return nil, err
}
count, err := d.GetClientCancellationsCount(username)
if err != nil {
return nil, err
}
settings, _ := d.GetSettings()
client, err := d.GetClientByUsername(username)
var currentAmende float64
if err == nil {
currentAmende = client.Amende
}
return map[string]any{
"cancellations_count": count,
"current_amende": currentAmende,
"next_penalty": nextPenalty,
"penalty_tiers": settings.PenaltyTiers,
"warning": "Une amende sera appliquée lors de la prochaine annulation tardive",
}, nil
}
+398
View File
@@ -0,0 +1,398 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"strconv"
"gorm.io/gorm"
)
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
func DefaultDeliverySchedule() models.DeliverySchedule {
day := models.DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
return models.DeliverySchedule{
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
Friday: day, Saturday: day, Sunday: day,
}
}
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
func CalcPointsFromTiers(total float64, tiers []models.PointsTier) int {
for _, t := range tiers {
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
return t.Points
}
}
return 0
}
// DefaultSettings retourne les paramètres par défaut
func DefaultSettings() models.AppSettings {
return models.AppSettings{
PenaltiesEnabled: true,
ShowAmendeScore: true,
PenaltyTiers: []models.PenaltyTier{
{MinCancel: 0, Amount: 20},
{MinCancel: 1, Amount: 50},
{MinCancel: 2, Amount: 100},
{MinCancel: 3, Amount: 150},
},
PointsEnabled: true,
ReferralEnabled: true,
ReferralAmount: 0,
PointsPools: []models.PointsPool{
{
Key: "pool_0",
Name: "Pool 1",
Categories: []string{},
Tiers: []models.PointsTier{
{Min: 30, Max: 50, Points: 1},
{Min: 60, Max: 150, Points: 2},
{Min: 160, Max: 300, Points: 3},
{Min: 310, Max: 400, Points: 5},
{Min: 401, Max: 0, Points: 10},
},
},
{
Key: "pool_1",
Name: "Pool 2",
Categories: []string{},
Tiers: []models.PointsTier{
{Min: 30, Max: 100, Points: 1},
{Min: 110, Max: 200, Points: 2},
{Min: 210, Max: 0, Points: 3},
},
},
},
ShopName: "Milieu-Nantais",
ContactTelegram: "MLN44LA",
DeliveryMode: models.DeliveryModeConfig{
Mode: "single",
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(),
PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
"44400", "44880", "44120", "44230", "44115",
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
}},
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
"44860", "44220", "44118", "44710", "44690", "44119",
}},
},
Telegram2FAEnabled: false,
}
}
// GetSettings récupère les paramètres depuis la DB
func (d *Database) GetSettings() (models.AppSettings, error) {
settings := DefaultSettings()
var rows []struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
if err := d.GDB.Table("app_settings").Select("key, value").Scan(&rows).Error; err != nil {
return settings, fmt.Errorf("erreur lecture settings: %w", err)
}
for _, row := range rows {
switch row.Key {
case "penalties_enabled":
settings.PenaltiesEnabled = row.Value == "true"
case "penalty_tiers":
var tiers []models.PenaltyTier
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
settings.PenaltyTiers = tiers
}
case "show_amende_score":
settings.ShowAmendeScore = row.Value == "true"
case "points_enabled":
settings.PointsEnabled = row.Value == "true"
case "points_pools":
var pools []models.PointsPool
if err := json.Unmarshal([]byte(row.Value), &pools); err == nil {
settings.PointsPools = pools
}
case "points_reward":
// row.Value peut valoir la chaîne littérale "null" (récompense
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
// donc sans ce garde-fou &reward pointerait vers une struct vide
// mais non-nil, et la récompense réapparaîtrait activée.
if row.Value != "null" && row.Value != "" {
var reward models.PointsReward
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
settings.PointsReward = &reward
}
}
case "promotions_enabled":
settings.PromotionsEnabled = row.Value == "true"
case "promotions":
var promotions []models.CategoryPromotionConfig
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
settings.Promotions = promotions
}
case "free_gifts_enabled":
settings.FreeGiftsEnabled = row.Value == "true"
case "free_gifts":
var freeGifts []models.CategoryFreeGiftConfig
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
settings.FreeGifts = freeGifts
}
case "referral_enabled":
settings.ReferralEnabled = row.Value == "true"
case "referral_amount":
if v, err := strconv.ParseFloat(row.Value, 64); err == nil {
settings.ReferralAmount = v
}
case "crypto_payment_enabled":
settings.CryptoPaymentEnabled = row.Value == "true"
case "crypto_only":
settings.CryptoOnly = row.Value == "true"
case "nowpayments_api_key":
settings.NowPaymentsAPIKey = row.Value
case "nowpayments_ipn_secret":
settings.NowPaymentsIPNSecret = row.Value
case "nowpayments_currencies":
var currencies []string
if err := json.Unmarshal([]byte(row.Value), &currencies); err == nil {
settings.NowPaymentsCurrencies = currencies
}
case "delivery_schedule":
var sched models.DeliverySchedule
if err := json.Unmarshal([]byte(row.Value), &sched); err == nil {
settings.DeliverySchedule = sched
}
case "postal_zones":
var zones []models.PostalZone
if err := json.Unmarshal([]byte(row.Value), &zones); err == nil {
settings.PostalZones = zones
}
case "contact_telegram":
settings.ContactTelegram = row.Value
case "telegram_bot_token":
settings.TelegramBotToken = row.Value
case "telegram_bot_username":
settings.TelegramBotUsername = row.Value
case "telegram_notifications_enabled":
settings.TelegramNotificationsEnabled = row.Value == "true"
case "delivery_mode":
var mode models.DeliveryModeConfig
if err := json.Unmarshal([]byte(row.Value), &mode); err == nil {
settings.DeliveryMode = mode
}
case "telegram_2fa_enabled":
settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name":
settings.ShopName = row.Value
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
}
// UpdateSettings sauvegarde les paramètres dans la DB
func (d *Database) UpdateSettings(s models.AppSettings) error {
boolStr := func(b bool) string {
if b {
return "true"
}
return "false"
}
if s.PenaltyTiers == nil {
s.PenaltyTiers = []models.PenaltyTier{}
}
tiersJSON, err := json.Marshal(s.PenaltyTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
}
if s.PointsPools == nil {
s.PointsPools = []models.PointsPool{}
}
for i := range s.PointsPools {
if s.PointsPools[i].Categories == nil {
s.PointsPools[i].Categories = []string{}
}
if s.PointsPools[i].Tiers == nil {
s.PointsPools[i].Tiers = []models.PointsTier{}
}
}
poolsJSON, err := json.Marshal(s.PointsPools)
if err != nil {
return fmt.Errorf("erreur sérialisation pools: %w", err)
}
if s.PointsReward != nil {
for i := range s.PointsReward.CategoryConfigs {
if s.PointsReward.CategoryConfigs[i].Products == nil {
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
}
}
}
rewardJSON, err := json.Marshal(s.PointsReward)
if err != nil {
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
}
if s.Promotions == nil {
s.Promotions = []models.CategoryPromotionConfig{}
}
for i := range s.Promotions {
if s.Promotions[i].Products == nil {
s.Promotions[i].Products = []models.PromotionProductQuantity{}
}
}
promotionsJSON, err := json.Marshal(s.Promotions)
if err != nil {
return fmt.Errorf("erreur sérialisation promotions: %w", err)
}
if s.FreeGifts == nil {
s.FreeGifts = []models.CategoryFreeGiftConfig{}
}
for i := range s.FreeGifts {
if s.FreeGifts[i].Tiers == nil {
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
}
if s.FreeGifts[i].Products == nil {
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
}
for j := range s.FreeGifts[i].Products {
if s.FreeGifts[i].Products[j].Tiers == nil {
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
}
}
}
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
if err != nil {
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
}
if s.NowPaymentsCurrencies == nil {
s.NowPaymentsCurrencies = []string{}
}
currenciesJSON, err := json.Marshal(s.NowPaymentsCurrencies)
if err != nil {
return fmt.Errorf("erreur sérialisation nowpayments_currencies: %w", err)
}
schedJSON, err := json.Marshal(s.DeliverySchedule)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
}
if s.PostalZones == nil {
s.PostalZones = []models.PostalZone{}
}
zonesJSON, err := json.Marshal(s.PostalZones)
if err != nil {
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
}
if s.DeliveryMode.CategoryRoutes == nil {
s.DeliveryMode.CategoryRoutes = []models.CategoryRoute{}
}
deliveryModeJSON, err := json.Marshal(s.DeliveryMode)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_mode: %w", err)
}
if s.ContactTelegram == "" {
s.ContactTelegram = "MLN44LA"
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"penalty_tiers", string(tiersJSON)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_pools", string(poolsJSON)},
{"points_reward", string(rewardJSON)},
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
{"promotions", string(promotionsJSON)},
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
{"free_gifts", string(freeGiftsJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
{"crypto_only", boolStr(s.CryptoOnly)},
{"nowpayments_api_key", s.NowPaymentsAPIKey},
{"nowpayments_ipn_secret", s.NowPaymentsIPNSecret},
{"nowpayments_currencies", string(currenciesJSON)},
{"delivery_schedule", string(schedJSON)},
{"postal_zones", string(zonesJSON)},
{"telegram_bot_token", s.TelegramBotToken},
{"telegram_bot_username", s.TelegramBotUsername},
{"telegram_notifications_enabled", boolStr(s.TelegramNotificationsEnabled)},
{"telegram_2fa_enabled", boolStr(s.Telegram2FAEnabled)},
{"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName},
{"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 (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
return d.GDB.Transaction(func(tx *gorm.DB) error {
for _, p := range pairs {
if err := tx.Exec(upsert, p[0], p[1]).Error; err != nil {
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
}
}
return nil
})
}
+495
View File
@@ -0,0 +1,495 @@
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
}
// TotalPromoDiscount renvoie le montant total (€) des réductions de prix
// accordées par des promotions sur les commandes approuvées, filtré par le
// reset "revenus" (même périmètre que TotalRevenue, dont c'est un
// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment
// de AddToBasket — reflète donc les promos réellement appliquées à l'époque,
// pas la config de promotions courante.
func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var total float64
query := `
SELECT COALESCE(SUM(ci.promo_discount), 0)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ` + where
err := d.GDB.Raw(query, args...).Scan(&total).Error
return total, err
}
// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées)
// ayant bénéficié d'au moins une réduction de prix promo, filtré par le
// reset "revenus".
func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at")
var count int64
query := `
SELECT COUNT(DISTINCT ci.command_id)
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
WHERE ci.promo_discount > 0 AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, 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
}
+202
View File
@@ -0,0 +1,202 @@
package db
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
// MigrateAddTelegramColumns ajoute les colonnes telegram_chat_id si elles n'existent pas
func (d *Database) MigrateAddTelegramColumns() {
migrations := []string{
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`,
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS two_fa_enabled BOOLEAN NOT NULL DEFAULT FALSE`,
}
for _, q := range migrations {
if err := d.GDB.Exec(q).Error; err != nil {
log.Printf("⚠️ [TELEGRAM_MIGRATION] %v", err)
}
}
log.Println("✅ [TELEGRAM] Colonnes telegram_chat_id vérifiées")
}
const linkTokenTTL = 10 * time.Minute
// GenerateLinkToken crée un token aléatoire sécurisé et le stocke dans Redis (10 min)
func GenerateLinkToken(username, role string) (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("génération token: %w", err)
}
token := hex.EncodeToString(raw)
data := models.TelegramLinkData{Username: username, Role: role}
val, err := json.Marshal(data)
if err != nil {
return "", err
}
key := fmt.Sprintf("telegram:link:%s", token)
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
return "", fmt.Errorf("redis set: %w", err)
}
return token, nil
}
// ValidateAndConsumeLinkToken valide le token, retourne les données, puis le supprime
func ValidateAndConsumeLinkToken(token string) (username, role string, err error) {
key := fmt.Sprintf("telegram:link:%s", token)
val, err := Redis.Get(RedisCtx, key).Bytes()
if err != nil {
return "", "", fmt.Errorf("token invalide ou expiré")
}
var data models.TelegramLinkData
if err := json.Unmarshal(val, &data); err != nil {
return "", "", fmt.Errorf("données corrompues")
}
Redis.Del(RedisCtx, key)
return data.Username, data.Role, nil
}
func (d *Database) SaveClientTelegramChatID(username string, chatID int64) error {
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
}
func (d *Database) GetClientTelegramChatID(username string) (int64, bool, error) {
var result struct {
ChatID *int64 `gorm:"column:telegram_chat_id"`
}
if err := d.GDB.Table("clients").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
return 0, false, err
}
if result.ChatID == nil {
return 0, false, nil
}
return *result.ChatID, true, nil
}
func (d *Database) DeleteClientTelegramChatID(username string) error {
return d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
}
func (d *Database) SaveUserTelegramChatID(username string, chatID int64) error {
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", chatID).Error
}
func (d *Database) GetUserTelegramChatID(username string) (int64, bool, error) {
var result struct {
ChatID *int64 `gorm:"column:telegram_chat_id"`
}
if err := d.GDB.Table("users").Select("telegram_chat_id").Where("username = ?", username).Limit(1).Scan(&result).Error; err != nil {
return 0, false, err
}
if result.ChatID == nil {
return 0, false, nil
}
return *result.ChatID, true, nil
}
func (d *Database) DeleteUserTelegramChatID(username string) error {
return d.GDB.Model(&models.User{}).Where("username = ?", username).Update("telegram_chat_id", nil).Error
}
// 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
func (d *Database) GetUserByTelegramChatID(chatID int64) (username, role string, err error) {
var clientResult struct {
Username string `gorm:"column:username"`
}
if err = d.GDB.Table("clients").Select("username").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&clientResult).Error; err == nil && clientResult.Username != "" {
return clientResult.Username, "client", nil
}
var userResult struct {
Username string `gorm:"column:username"`
Role string `gorm:"column:role"`
}
if err = d.GDB.Model(&models.User{}).Select("username, role").Where("telegram_chat_id = ?", chatID).Limit(1).Scan(&userResult).Error; err == nil && userResult.Username != "" {
return userResult.Username, userResult.Role, nil
}
return "", "", fmt.Errorf("aucun compte lié à ce chat_id")
}
// ── 2FA sessions ─────────────────────────────────────────────────────────────
const twoFASessionTTL = 5 * time.Minute
type twoFASessionData struct {
Username string `json:"username"`
Code string `json:"code"`
}
func Store2FASession(sessionToken, username, code string) error {
data, err := json.Marshal(twoFASessionData{Username: username, Code: code})
if err != nil {
return err
}
return Redis.Set(RedisCtx, "2fa:session:"+sessionToken, data, twoFASessionTTL).Err()
}
// Verify2FASession valide le code et retourne le username. GETDEL = atomique (anti-replay).
func Verify2FASession(sessionToken, code string) (string, error) {
val, err := Redis.GetDel(RedisCtx, "2fa:session:"+sessionToken).Bytes()
if err != nil {
return "", fmt.Errorf("session invalide ou expirée")
}
var d twoFASessionData
if err := json.Unmarshal(val, &d); err != nil {
return "", fmt.Errorf("données corrompues")
}
if d.Code != code {
return "", fmt.Errorf("code incorrect")
}
return d.Username, nil
}
+90
View File
@@ -0,0 +1,90 @@
package db
import (
"fmt"
"gestion/models"
"log"
)
func (d *Database) CreateUser(user *models.User) error {
if err := d.GDB.Create(user).Error; err != nil {
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
}
return nil
}
func (d *Database) GetAllUsers() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
}
return users, nil
}
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
var users []*models.User
if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
}
return users, nil
}
func (d *Database) UpdateUser(user *models.User) error {
result := d.GDB.Model(user).Updates(models.User{
Username: user.Username,
Password: user.Password,
Role: user.Role,
})
if result.Error != nil {
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
return nil
}
func (d *Database) DeleteUser(id int) error {
var user models.User
if err := d.GDB.First(&user, id).Error; err != nil {
if isNotFound(err) {
return fmt.Errorf("utilisateur non trouvé")
}
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
}
_ = d.RevokeAllUserTokens(id, user.Role)
result := d.GDB.Delete(&models.User{}, id)
if result.Error != nil {
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("utilisateur non trouvé")
}
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, user.Role)
return nil
}
func (d *Database) GetUserByID(id int) (*models.User, error) {
var user models.User
if err := d.GDB.First(&user, id).Error; err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("utilisateur non trouvé")
}
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
}
return &user, nil
}
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
var user models.User
if err := d.GDB.Where("username = ?", username).First(&user).Error; err != nil {
if isNotFound(err) {
return nil, fmt.Errorf("utilisateur non trouvé")
}
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
}
return &user, nil
}
+7
View File
@@ -0,0 +1,7 @@
package db
import "gorm.io/gorm"
func isNotFound(err error) bool {
return err == gorm.ErrRecordNotFound
}
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"context"
"gestion/models"
"log"
"os"
"github.com/redis/go-redis/v9"
)
var (
Redis *redis.Client
RedisCtx = context.Background()
)
type CommandWithDistance struct {
CommandID int
Address string
Lat float64
Lng float64
Distance float64
EstimatedETA int
QueueItem models.CommandQueue
}
const (
// Temps moyen estimé par livraison (en minutes)
AVG_DELIVERY_TIME = 15
// Nombre maximum de commandes par livreur
MAX_COMMANDS_PER_DELIVERYMAN = 20
)
// ============================================
// INITIALISATION DE REDIS
// ============================================
func InitRedis() {
host := getEnv("REDIS_HOST", "redis")
port := getEnv("REDIS_PORT", "6379")
password := os.Getenv("REDIS_PASSWORD")
addr := host + ":" + port
Redis = redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
_, err := Redis.Ping(RedisCtx).Result()
if err != nil {
log.Fatalf("❌ Erreur connexion Redis: %v", err)
}
log.Println("⚡ Redis connecté")
}
@@ -0,0 +1,119 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"math"
)
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
keys, err := scanRedisKeys("delivery:status:*")
if err != nil || len(keys) == 0 {
return "", fmt.Errorf("aucun livreur trouvé")
}
var leastLoaded string
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
for _, key := range keys {
username := key[len("delivery:status:"):]
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(data), &status)
if status.Status == "offline" {
continue
}
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
if !d.CanDeliverymanAcceptCommands(username) {
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
continue
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Priorité aux livreurs disponibles (bonus de -1000)
effectiveQueueSize := queueSize
if status.Status == "available" {
effectiveQueueSize -= 1000
}
if effectiveQueueSize < minQueueSize {
minQueueSize = effectiveQueueSize
leastLoaded = username
}
}
if leastLoaded == "" {
return "", fmt.Errorf("aucun livreur disponible")
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
leastLoaded, actualQueueSize)
return leastLoaded, nil
}
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
if err != nil || len(activeUsernames) == 0 {
return "", 0, fmt.Errorf("aucun livreur actif")
}
var leastLoaded string
var minQueueSize int64 = math.MaxInt64
for _, username := range activeUsernames {
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if queueSize < minQueueSize {
minQueueSize = queueSize
leastLoaded = username
}
}
return leastLoaded, minQueueSize, nil
}
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
if err != nil || len(activeUsernames) == 0 {
return false, 0, fmt.Errorf("aucun livreur actif")
}
if len(activeUsernames) == 1 {
return false, 1, nil
}
atCapacityCount := 0
for _, username := range activeUsernames {
if !d.CanDeliverymanAcceptCommands(username) {
atCapacityCount++
}
}
allAtCapacity := atCapacityCount == len(activeUsernames)
if allAtCapacity {
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
atCapacityCount, len(activeUsernames))
}
return allAtCapacity, len(activeUsernames), nil
}
@@ -0,0 +1,104 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
)
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
key := fmt.Sprintf("delivery:location:%s", username)
location := map[string]any{
"latitude": lat,
"longitude": lon,
"last_update": time.Now().Unix(),
}
data, _ := json.Marshal(location)
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
if err != nil {
return fmt.Errorf("erreur mise à jour position: %w", err)
}
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil || statusData == "" {
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
var status models.DeliveryPersonStatus
json.Unmarshal([]byte(statusData), &status)
if status.Status == "offline" {
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
d.SetDeliveryPersonStatus(username, "available", 0)
} else {
go d.UpdateDeliverymanStatusBasedOnQueue(username)
}
}
// 3️⃣ Publier l'événement de mise à jour de position
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
return nil
}
// GetDeliveryPersonLocation récupère la position d'un livreur
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
key := fmt.Sprintf("delivery:location:%s", username)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
}
if data == "" {
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
}
var location map[string]any
if err := json.Unmarshal([]byte(data), &location); err != nil {
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
}
var lat, lon float64
if v, ok := location["latitude"]; ok && v != nil {
switch val := v.(type) {
case float64:
lat = val
case float32:
lat = float64(val)
case int:
lat = float64(val)
case int64:
lat = float64(val)
case string:
lat, _ = strconv.ParseFloat(val, 64)
}
}
if v, ok := location["longitude"]; ok && v != nil {
switch val := v.(type) {
case float64:
lon = val
case float32:
lon = float64(val)
case int:
lon = float64(val)
case int64:
lon = float64(val)
case string:
lon, _ = strconv.ParseFloat(val, 64)
}
}
if lat == 0 && lon == 0 {
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
}
return lat, lon, nil
}
+183
View File
@@ -0,0 +1,183 @@
package db
import (
"fmt"
"gestion/services"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
func (d *Database) SetCommandETA(commandID, minutes int) error {
key := fmt.Sprintf("command:eta:%d", commandID)
now := time.Now()
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
eta := map[string]any{
"command_id": commandID,
"total_eta_minutes": minutes,
"eta_minutes": minutes,
"wait_time_minutes": 0,
"travel_time_minutes": minutes,
"queue_position": 1,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
}
_, err := Redis.HSet(RedisCtx, key, eta).Result()
if err != nil {
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
}
Redis.Expire(RedisCtx, key, 2*time.Hour)
d.ScheduleETANotifications(commandID, minutes)
return nil
}
// GetCommandETA récupère l'ETA d'une commande depuis Redis
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
key := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
if err != nil {
return nil, err
}
if len(etaData) == 0 {
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
}
return etaData, nil
}
// CheckCommandETAExists vérifie si un ETA existe pour une commande
func (d *Database) CheckCommandETAExists(commandID int) bool {
key := fmt.Sprintf("command:eta:%d", commandID)
exists, _ := Redis.Exists(RedisCtx, key).Result()
return exists > 0
}
// ScheduleETANotifications programme les notifications 5min et 3min
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
if etaMinutes > 5 {
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
Score: float64(notify5min),
Member: fmt.Sprintf("%d:5min", commandID),
})
}
if etaMinutes > 3 {
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
Score: float64(notify3min),
Member: fmt.Sprintf("%d:3min", commandID),
})
}
return nil
}
// ProcessScheduledNotifications traite les notifications à envoyer
func (d *Database) ProcessScheduledNotifications() error {
now := float64(time.Now().Unix())
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
Min: "0",
Max: strconv.FormatFloat(now, 'f', 0, 64),
}).Result()
if err != nil {
return err
}
for _, result := range results {
parts := splitNotificationKey(result)
commandID, _ := strconv.Atoi(parts[0])
notifType := parts[1]
d.SendETANotification(commandID, notifType)
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
}
return nil
}
// SendETANotification envoie une notification ETA
func (d *Database) SendETANotification(commandID int, notifType string) {
message := fmt.Sprintf("Votre commande #%d arrive dans %s", d.GetClientOrderID(commandID), notifType)
channel := fmt.Sprintf("notifications:command:%d", commandID)
Redis.Publish(RedisCtx, channel, message)
}
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
return services.MinETA
}
from := services.Coordinates{
Latitude: livreurLat,
Longitude: livreurLng,
}
to := services.Coordinates{
Latitude: destLat,
Longitude: destLng,
}
eta, _, err2 := services.CalculateETAWithTomTom(from, to)
if err2 != nil {
distance := services.CalculateDistance(from, to)
eta = services.CalculateETA(distance)
}
return eta
}
// CalculateDistanceBetweenPoints calcule la distance entre deux points
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
return services.CalculateDistance(from, to)
}
// CalculateETABetweenPoints calcule l'ETA entre deux points
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
return services.CalculateETA(distance)
}
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
key := fmt.Sprintf("command:eta:%d", commandID)
now := time.Now()
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
eta := map[string]any{
"command_id": commandID,
"total_eta_minutes": totalETA,
"eta_minutes": totalETA,
"queue_position": queuePosition,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
}
_, err := Redis.HSet(RedisCtx, key, eta).Result()
if err != nil {
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
}
Redis.Expire(RedisCtx, key, 4*time.Hour)
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package db
import "fmt"
func extractCommandID(member any) int {
switch v := member.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
var id int
fmt.Sscanf(v, "%d", &id)
return id
default:
return 0
}
}
func splitNotificationKey(key string) []string {
for i, c := range key {
if c == ':' {
return []string{key[:i], key[i+1:]}
}
}
return []string{key, ""}
}
+107
View File
@@ -0,0 +1,107 @@
// db/redis_position.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"math"
"time"
)
// ============================================
// GESTION DES POSITIONS GPS
// ============================================
// ============================================
// LEGACY: UpdateLivreurPosition (compatibilité)
// ============================================
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
// 🔹 1️⃣ Validation du username côté serveur
if authUsername == "" {
return fmt.Errorf("username non fourni ou non authentifié")
}
// 🔹 2️⃣ Validation des coordonnées GPS
if !isValidCoordinates(lat, lon) {
return fmt.Errorf("coordonnées GPS invalides")
}
// 🔹 3️⃣ Mise à jour position GPS (Redis)
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
position := models.LivreurPosition{
Latitude: lat,
Longitude: lon,
UpdatedAt: time.Now(),
Status: status,
}
positionData, err := json.Marshal(position)
if err != nil {
return fmt.Errorf("erreur serialisation position: %w", err)
}
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur mise à jour position: %w", err)
}
// 🔹 4️⃣ Mise à jour statut sécurisé
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
var deliveryStatus models.DeliveryPersonStatus
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
if existingData != "" {
json.Unmarshal([]byte(existingData), &deliveryStatus)
deliveryStatus.Status = status
deliveryStatus.LastUpdate = time.Now()
} else {
deliveryStatus = models.DeliveryPersonStatus{
Username: authUsername,
Status: status,
CurrentCommand: 0,
LastUpdate: time.Now(),
}
}
statusData, err := json.Marshal(deliveryStatus)
if err != nil {
return fmt.Errorf("erreur serialisation statut: %w", err)
}
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
return nil
}
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
if authUsername == "" {
return nil, fmt.Errorf("username non fourni ou non authentifié")
}
key := fmt.Sprintf("livreur:position:%s", authUsername)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return nil, fmt.Errorf("position non trouvée: %w", err)
}
var position models.LivreurPosition
if err := json.Unmarshal([]byte(data), &position); err != nil {
return nil, fmt.Errorf("erreur parsing position: %w", err)
}
return &position, nil
}
// Vérifie si la latitude et longitude sont valides
func isValidCoordinates(lat, lon float64) bool {
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
lat >= -90 && lat <= 90 &&
lon >= -180 && lon <= 180
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"encoding/json"
"fmt"
"log"
"time"
)
// PublishCommandEvent publie un événement de commande
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
channel := fmt.Sprintf("events:command:%d", commandID)
event := map[string]any{
"command_id": commandID,
"type": eventType,
"message": message,
"timestamp": time.Now().Unix(),
}
data, _ := json.Marshal(event)
Redis.Publish(RedisCtx, channel, data)
}
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
message := map[string]any{
"type": "location_update",
"username": username,
"latitude": lat,
"longitude": lon,
"timestamp": time.Now().Unix(),
}
data, _ := json.Marshal(message)
Redis.Publish(RedisCtx, "delivery:events", data)
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
}
@@ -0,0 +1,390 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
)
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
activeCount, _ := d.CountActiveDeliverymen()
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// Si plusieurs livreurs, appliquer la limite de 10
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
}
totalETA := estimatedTravelTime
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var address string
if addr, ok := command["delivery_address"].(string); ok {
address = addr
} else if addr, ok := command["adresse"].(string); ok {
address = addr
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
limitInfo := ""
if activeCount > 1 {
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
} else {
limitInfo = " (sans limite - seul livreur)"
}
d.AddCommandLog(commandID, "queued",
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
deliveryman, currentQueueSize+1, limitInfo, totalETA),
"system")
return nil
}
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
activeCount, _ := d.CountActiveDeliverymen()
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
}
// ✅ ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var username string
if u, ok := command["username"].(string); ok {
username = u
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: username,
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
updateQuery := `UPDATE commandes
SET livreur_assign = $1,
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`
_, err = d.Exec(updateQuery, deliveryman, commandID)
if err != nil {
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
return fmt.Errorf("erreur mise à jour DB: %w", err)
}
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
// Sauvegarder dans le cache destination
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": lat,
"lon": lng,
})
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
limitInfo := ""
if activeCount > 1 {
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
} else {
limitInfo = " (sans limite - seul livreur)"
}
d.AddCommandLog(commandID, "assigned",
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
"system")
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
return nil
}
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var username string
if u, ok := command["username"].(string); ok {
username = u
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: username,
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
// Sauvegarder dans le cache destination
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": lat,
"lon": lng,
})
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
d.AddCommandLog(commandID, "force_queued",
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
deliveryman, currentQueueSize+1, totalETA, lat, lng),
"system")
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
return nil
}
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
totalETA := estimatedTravelTime
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
var address string
if addr, ok := command["delivery_address"].(string); ok {
address = addr
} else if addr, ok := command["adresse"].(string); ok {
address = addr
}
queueItem := models.CommandQueue{
CommandID: commandID,
Username: command["username"].(string),
TotalPrice: totalPrice,
Address: address,
Lat: lat,
Lng: lng,
CreatedAt: time.Now(),
EstimatedETA: totalETA,
}
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
if err != nil {
return fmt.Errorf("erreur ajout à la queue: %w", err)
}
d.UpdateCommandStatus(commandID, "assigned")
d.AssignDeliveryPerson(commandID, deliveryman)
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
d.AddCommandLog(commandID, "force_queued",
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
deliveryman, currentQueueSize+1, totalETA),
"system")
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
commandID, deliveryman, currentQueueSize+1, totalETA)
return nil
}
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
func (d *Database) AutoAssignCommand(commandID int) error {
// Récupérer les livreurs disponibles
available, err := d.GetAvailableDeliveryPersonsRedis()
if err != nil || len(available) == 0 {
return fmt.Errorf("aucun livreur disponible")
}
// Prendre le premier livreur
livreur := available[0]
// Assigner dans la DB principale
err = d.AssignDeliveryPerson(commandID, livreur.Username)
if err != nil {
return err
}
// Mettre à jour le statut dans Redis
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
// Retirer de la file d'attente
d.RemoveCommandFromQueue(commandID)
// Définir l'ETA initial
d.SetCommandETA(commandID, 30)
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
return nil
}
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
// Vérifier d'abord la queue spécifique du livreur
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
if err == nil && len(results) > 0 {
// Une commande est dans sa queue - la traiter
commandID := extractCommandID(results[0].Member)
// Mettre à jour le statut
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
return nil
}
// Si aucune commande dans sa queue, chercher dans la queue générale
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
if err != nil || len(generalResults) == 0 {
log.Printf("️ Aucune commande en attente pour %s", deliveryman)
return nil
}
commandID := extractCommandID(generalResults[0].Member)
// Assigner la commande
err = d.AssignDeliveryPerson(commandID, deliveryman)
if err != nil {
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
return err
}
// Retirer de la queue générale
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
// Mettre à jour le statut
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
// Définir un ETA par défaut
d.SetCommandETA(commandID, 30)
// Ajouter log
d.AddCommandLog(commandID, "auto_assigned",
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
"system")
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
return nil
}
+203
View File
@@ -0,0 +1,203 @@
// db/redis_queue_cleanup.go
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"time"
)
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
keys, err := scanRedisKeys("queue:pending:*")
if err != nil {
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
}
removedCount := 0
validCount := 0
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
removedCount++
continue
}
isValid := true
reasons := []string{}
// 1. Vérifier Username
if queueItem.Username == "" {
isValid = false
reasons = append(reasons, "username vide")
}
// 2. Vérifier Address
if queueItem.Address == "" {
isValid = false
reasons = append(reasons, "adresse vide")
}
// 3. Vérifier Coordinates
if queueItem.Lat == 0 || queueItem.Lng == 0 {
isValid = false
reasons = append(reasons, "coordonnées GPS manquantes")
}
// 4. Vérifier CreatedAt
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
isValid = false
reasons = append(reasons, "date de création invalide")
}
// 5. Vérifier TotalPrice (optionnel mais recommandé)
if queueItem.TotalPrice <= 0 {
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
}
// ❌ SUPPRIMER SI INVALIDE
if !isValid {
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
removedCount++
} else {
validCount++
}
}
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
return removedCount, nil
}
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
commandIDStr := fmt.Sprintf("%d", commandID)
// 1. Supprimer la clé de données
Redis.Del(RedisCtx, key)
// 2. Supprimer de la queue générale
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// 3. Supprimer des queues de livreurs
livreurKeys, _ := scanRedisKeys("queue:deliveryman:*")
for _, queueKey := range livreurKeys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
}
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
}
// 4. Supprimer l'ETA si existe
etaKey := fmt.Sprintf("command:eta:%d", commandID)
Redis.Del(RedisCtx, etaKey)
// 5. Supprimer le cache de destination
destKey := fmt.Sprintf("command:destination:%d", commandID)
Redis.Del(RedisCtx, destKey)
// 6. Logger dans la base de données
d.AddCommandLog(commandID, "cleanup_removed",
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
"system")
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
}
// ============================================
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
// ============================================
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
command, err := d.GetCommandByID(commandID)
if err != nil {
return fmt.Errorf("commande introuvable: %w", err)
}
// ✅ VALIDATION STRICTE
errors := []string{}
// 1. Username
username, ok := command["username"].(string)
if !ok || username == "" {
errors = append(errors, "username manquant")
}
// 2. Address
address := ""
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
address = addr
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
address = addr
}
if address == "" {
errors = append(errors, "adresse de livraison manquante")
}
// 3. Coordinates
var lat, lng float64
if command["dest_latitude"] != nil {
if latVal, ok := command["dest_latitude"].(float64); ok {
lat = latVal
}
}
if command["dest_longitude"] != nil {
if lngVal, ok := command["dest_longitude"].(float64); ok {
lng = lngVal
}
}
if lat == 0 || lng == 0 {
errors = append(errors, "coordonnées GPS manquantes")
}
// 4. Total Price
var totalPrice float64
if tp, ok := command["total_prix"].(float64); ok {
totalPrice = tp
}
if totalPrice <= 0 {
errors = append(errors, "prix total invalide")
}
if len(errors) > 0 {
return fmt.Errorf("validation échouée: %v", errors)
}
return nil
}
// ============================================
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
// ============================================
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
func (d *Database) StartQueueCleanupScheduler() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
for range ticker.C {
removed, err := d.CleanupInvalidQueueCommands()
if err != nil {
log.Printf("❌ [CLEANUP] Erreur: %v", err)
} else if removed > 0 {
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
}
}
}()
}
+147
View File
@@ -0,0 +1,147 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"time"
)
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]any, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
activeCount, _ := d.CountActiveDeliverymen()
var commands []map[string]any
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
commands = append(commands, map[string]any{
"position": i + 1,
"command_id": commandID,
"address": queueItem.Address,
"estimated_eta": queueItem.EstimatedETA,
"created_at": queueItem.CreatedAt,
"lat": queueItem.Lat,
"lng": queueItem.Lng,
})
}
canAcceptMore := true
if activeCount > 1 {
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
}
return map[string]any{
"deliveryman": deliveryman,
"queue_size": queueSize,
"commands": commands,
"can_accept_more": canAcceptMore,
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
"active_deliverymen": activeCount,
}, nil
}
func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
overview := make(map[string]any)
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
overview["general_queue"] = generalQueueSize
deliverymanQueues := make(map[string]any)
keys, _ := scanRedisKeys("queue:deliveryman:*")
var totalPending int64 = generalQueueSize
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
username := key[len("queue:deliveryman:"):]
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
deliverymanQueues[username] = map[string]any{
"queue_size": queueSize,
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
}
totalPending += queueSize
}
overview["total_pending"] = totalPending
return overview, nil
}
func (d *Database) GetQueueStats() (map[string]any, error) {
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
var deliverymanQueueCount int64
keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" {
continue
}
count, _ := Redis.ZCard(RedisCtx, key).Result()
deliverymanQueueCount += count
}
var totalWaitTime int64
var commandCount int64
// 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 {
commandID := extractCommandID(result.Member)
if commandID <= 0 {
continue
}
key := fmt.Sprintf("queue:pending:%d", commandID)
data, _ := Redis.Get(RedisCtx, key).Result()
var item models.CommandQueue
if json.Unmarshal([]byte(data), &item) == nil {
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
totalWaitTime += waitMinutes
commandCount++
}
}
avgWaitTime := 0
if commandCount > 0 {
avgWaitTime = int(totalWaitTime / commandCount)
}
stats := map[string]any{
"total_pending": normalCount + priorityCount,
"general_queue": normalCount,
"priority_queue": priorityCount,
"deliveryman_queues": deliverymanQueueCount,
"avg_wait_time_min": avgWaitTime,
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
"avg_delivery_time": AVG_DELIVERY_TIME,
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
}
return stats, nil
}
@@ -0,0 +1,338 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"log"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
if err != nil {
return fmt.Errorf("erreur récupération taille queue: %w", err)
}
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
data, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
return nil
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
return err
}
var newStatus string
var currentCommand int
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
newStatus = "busy"
currentCommand = 0
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
} else {
if status.Status == "delivering" && status.CurrentCommand > 0 {
newStatus = "delivering"
currentCommand = status.CurrentCommand
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
deliveryman, queueSize, currentCommand)
} else {
newStatus = "available"
currentCommand = 0
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
}
}
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
}
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
data, err := Redis.Get(RedisCtx, statusKey).Result()
if err != nil {
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
return true
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
return true
}
if status.Status == "offline" {
return false
}
// 3. Vérifier la taille de la queue
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
// 4. Si queue >= 10, refuser
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
return false
}
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
return true
}
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem)
if err != nil {
return fmt.Errorf("erreur serialisation: %w", err)
}
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
score := float64(time.Now().Unix())
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
Score: score,
Member: queueItem.CommandID,
}).Err()
if err != nil {
return fmt.Errorf("erreur ajout queue Redis: %w", err)
}
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Incr(RedisCtx, counterKey)
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
return nil
}
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
data, err := json.Marshal(queueItem)
if err != nil {
return fmt.Errorf("erreur serialisation: %w", err)
}
score := float64(time.Now().Unix())
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
pipe := Redis.Pipeline()
pipe.Set(RedisCtx, key, data, 24*time.Hour)
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
Score: score,
Member: queueItem.CommandID,
})
_, err = pipe.Exec(RedisCtx)
if err != nil {
return fmt.Errorf("erreur ajout queue générale: %w", err)
}
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
return nil
}
func (d *Database) RemoveCommandFromQueue(commandID int) error {
key := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := strconv.Itoa(commandID)
pipe := Redis.Pipeline()
pipe.Del(RedisCtx, key)
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
// Trouver et retirer de la queue du livreur
keys, _ := scanRedisKeys("queue:deliveryman:*")
var affectedDeliveryman string
for _, queueKey := range keys {
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
continue
}
// Vérifier si la commande est dans cette queue
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err == nil {
// Commande trouvée dans cette queue
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
pipe.Decr(RedisCtx, counterKey)
break
}
}
_, err := pipe.Exec(RedisCtx)
if err != nil {
return fmt.Errorf("erreur suppression: %w", err)
}
log.Printf("✅ Commande %d retirée de la file", commandID)
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
if affectedDeliveryman != "" {
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
}
return nil
}
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
if err != nil || len(normalResults) == 0 {
return nil, fmt.Errorf("aucune commande en attente")
}
commandID := extractCommandID(normalResults[0].Member)
if commandID <= 0 {
return nil, fmt.Errorf("ID commande invalide")
}
key := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
return nil, fmt.Errorf("commande introuvable: %w", err)
}
var queue models.CommandQueue
if err := json.Unmarshal([]byte(data), &queue); err != nil {
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
return &queue, nil
}
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
key := fmt.Sprintf("delivery:status:%s", username)
statusData := models.DeliveryPersonStatus{
Username: username,
Status: status,
CurrentCommand: commandID,
LastUpdate: time.Now(),
}
data, _ := json.Marshal(statusData)
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
if err == nil {
log.Printf("✅ Statut livreur %s: %s", username, status)
}
return err
}
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
var available []models.DeliveryPersonStatus
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status == "available" {
available = append(available, s)
}
})
return available, err
}
// GetAllActiveDeliveryPersons retourne les livreurs actifs pouvant accepter des commandes
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
var active []models.DeliveryPersonStatus
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" && d.CanDeliverymanAcceptCommands(s.Username) {
active = append(active, s)
}
})
return active, err
}
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
func (d *Database) CountActiveDeliverymen() (int, error) {
count := 0
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" {
count++
}
})
return count, err
}
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
found := ""
d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) { //nolint
if found == "" && s.Status != "offline" {
found = s.Username
}
})
if found == "" {
return "", fmt.Errorf("aucun livreur actif trouvé")
}
return found, nil
}
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
var usernames []string
err := d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
if s.Status != "offline" {
usernames = append(usernames, s.Username)
}
})
return usernames, err
}
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
func (d *Database) SyncAllDeliverymanStatuses() error {
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
return d.iterDeliveryStatuses(func(s models.DeliveryPersonStatus) {
d.UpdateDeliverymanStatusBasedOnQueue(s.Username)
})
}
// iterDeliveryStatuses itère sur tous les statuts Redis des livreurs et appelle fn pour chacun.
func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) error {
keys, err := scanRedisKeys("delivery:status:*")
if err != nil {
return err
}
for _, key := range keys {
data, err := Redis.Get(RedisCtx, key).Result()
if err != nil {
continue
}
var status models.DeliveryPersonStatus
if err := json.Unmarshal([]byte(data), &status); err != nil {
continue
}
fn(status)
}
return nil
}
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
}
@@ -0,0 +1,339 @@
package db
import (
"encoding/json"
"fmt"
"gestion/models"
"gestion/services"
"log"
"math"
"sort"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
// Retirer la commande complétée de la queue
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
// Supprimer les données de la commande
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
Redis.Del(RedisCtx, commandKey)
// Supprimer le cache de destination
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
Redis.Del(RedisCtx, destCacheKey)
// Supprimer l'ETA
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
Redis.Del(RedisCtx, etaKey)
// Décrémenter le compteur
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
Redis.Decr(RedisCtx, counterKey)
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
// Vérifier s'il reste des commandes
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
if remainingCount == 0 {
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
// Chercher dans la queue générale
go d.ProcessNextCommandInQueue(deliveryman)
return nil
}
// ============================================
// 🔄 OPTIMISATION PAR PROXIMITÉ
// ============================================
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
if err != nil {
log.Printf("⚠️ Erreur optimisation queue: %v", err)
// Fallback: recalculer les ETAs sans réorganiser
d.RecalculateQueueETAs(deliveryman)
}
// Récupérer la prochaine commande (maintenant la plus proche)
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
if err == nil && nextCommand != nil {
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
}
return nil
}
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
return d.RecalculateQueueETAs(deliveryman)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil
}
// Collecter les informations de chaque commande avec sa distance
var commandsWithDistance []CommandWithDistance
for _, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
// 1. Essayer de récupérer depuis queue:pending:{id}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
var lat, lng float64
var address string
if err == nil && data != "" {
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
lat = queueItem.Lat
lng = queueItem.Lng
address = queueItem.Address
}
}
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
if lat == 0 && lng == 0 {
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := Redis.Get(RedisCtx, destCacheKey).Result()
if err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil {
lat = coords.Lat
lng = coords.Lon
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
}
}
}
// 3. Si toujours 0, récupérer depuis la DB
if lat == 0 && lng == 0 {
command, err := d.GetCommandByID(commandID)
if err == nil {
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
lat = dLat
}
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
lng = dLng
}
if address == "" {
if addr, ok := command["adresse"].(string); ok {
address = addr
}
}
}
}
// 4. Si toujours 0, utiliser une distance très grande
if lat == 0 && lng == 0 {
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
CommandID: commandID,
Address: address,
Lat: 0,
Lng: 0,
Distance: 999999,
EstimatedETA: 120,
QueueItem: models.CommandQueue{
CommandID: commandID,
Address: address,
Lat: 0,
Lng: 0,
},
})
continue
}
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
CommandID: commandID,
Address: address,
Lat: lat,
Lng: lng,
Distance: distance,
EstimatedETA: services.CalculateETA(distance),
QueueItem: models.CommandQueue{
CommandID: commandID,
Address: address,
Lat: lat,
Lng: lng,
},
})
}
if len(commandsWithDistance) == 0 {
return nil
}
// Trier par distance (la plus proche en premier)
sort.Slice(commandsWithDistance, func(i, j int) bool {
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
})
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
// Vider la queue actuelle
Redis.Del(RedisCtx, queueKey)
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
for i, cmd := range commandsWithDistance {
var travelTime int
var distance float64
if cmd.Lat != 0 && cmd.Lng != 0 {
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
travelTime = services.CalculateETA(distance)
} else {
distance = 0
travelTime = 10
}
// ✅ ETA = temps de trajet direct uniquement
cmd.QueueItem.EstimatedETA = travelTime
score := float64(i + 1)
data, _ := json.Marshal(cmd.QueueItem)
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
Score: score,
Member: cmd.CommandID,
})
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
i+1, cmd.CommandID, distance, travelTime)
}
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
return nil
}
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil
}
// ✅ Récupérer la position actuelle du livreur
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
return err
}
for i, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
queueItem.EstimatedETA = travelTime
updatedData, _ := json.Marshal(queueItem)
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
}
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
return nil
}
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
if err != nil {
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
}
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
if err != nil || len(commandIDs) == 0 {
return nil, 0, fmt.Errorf("queue vide")
}
var nearestCommand *models.CommandQueue
var nearestDistance float64 = math.MaxFloat64
var nearestETA int
for _, cmdIDStr := range commandIDs {
commandID := extractCommandID(cmdIDStr)
if commandID <= 0 {
continue
}
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
data, err := Redis.Get(RedisCtx, commandKey).Result()
if err != nil {
continue
}
var queueItem models.CommandQueue
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
continue
}
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
if distance < nearestDistance {
nearestDistance = distance
nearestCommand = &queueItem
nearestETA = services.CalculateETA(distance)
}
}
if nearestCommand == nil {
return nil, 0, fmt.Errorf("aucune commande trouvée")
}
return nearestCommand, nearestETA, nil
}
+184
View File
@@ -0,0 +1,184 @@
package db
import (
"encoding/json"
"fmt"
"log"
"time"
)
// ============================================
// SESSION REDIS - GESTION UTILISATEUR
// ============================================
// SessionData représente une session client en Redis
type SessionData struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
SessionID string `json:"session_id"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
LastActivity int64 `json:"last_activity"`
ExpiresAt int64 `json:"expires_at"`
BasketVersion int `json:"basket_version"`
PointsCache int `json:"points_cache"`
PenaltyCache float64 `json:"penalty_cache"`
Tags []string `json:"tags,omitempty"`
}
// ============================================
// CRÉER UNE SESSION CLIENT
// ============================================
// CreateClientSession crée une session Redis pour un client authentifié
// Appelé depuis handlers/auth.go après LoginClient réussi
//
// Exemple d'utilisation:
//
// sessionID := uuid.New().String()
// database.CreateClientSession(clientID, username, sessionID)
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
sessionKey := fmt.Sprintf("session:client:%d", clientID)
now := time.Now().Unix()
expiresAt := now + (5 * 3600) // 5 heures
sessionData := SessionData{
ClientID: clientID,
Username: username,
SessionID: sessionID,
Role: "client",
CreatedAt: now,
LastActivity: now,
ExpiresAt: expiresAt,
BasketVersion: 0,
PointsCache: 0,
PenaltyCache: 0,
}
// Sérialiser et sauvegarder
sessionJSON, err := json.Marshal(sessionData)
if err != nil {
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
return fmt.Errorf("erreur sérialisation session: %w", err)
}
ttl := time.Duration(expiresAt-now) * time.Second
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
}
// Ajouter à l'index des sessions actives
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
}
// Charger les infos du client (points, penalty) dans le cache
if client, err := d.GetClientByUsername(username); err == nil {
sessionData.PenaltyCache = float64(client.Amende)
sessionJSON, _ := json.Marshal(sessionData)
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
}
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
return nil
}
// ============================================
// RÉCUPÉRER UNE SESSION CLIENT
// ============================================
// GetClientSession récupère la session Redis d'un client
// Retourne nil si session expirée ou inexistante
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
data, err := Redis.Get(RedisCtx, sessionKey).Result()
if err != nil {
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
return nil, fmt.Errorf("session non trouvée")
}
var session SessionData
if err := json.Unmarshal([]byte(data), &session); err != nil {
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
return nil, fmt.Errorf("erreur désérialisation: %w", err)
}
// Vérifier si session expirée
if time.Now().Unix() > session.ExpiresAt {
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
Redis.Del(RedisCtx, sessionKey)
return nil, fmt.Errorf("session expirée")
}
return &session, nil
}
// ============================================
// METTRE À JOUR L'ACTIVITÉ DE SESSION
// ============================================
// RefreshSessionTimeout prolonge la durée de vie de la session
// Appelé régulièrement par SessionMiddleware (chaque requête client)
func (d *Database) RefreshSessionTimeout(clientID int) error {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
// Récupérer la session
data, err := Redis.Get(RedisCtx, sessionKey).Result()
if err != nil {
return fmt.Errorf("session non trouvée")
}
var session SessionData
if err := json.Unmarshal([]byte(data), &session); err != nil {
return fmt.Errorf("erreur désérialisation: %w", err)
}
// Mettre à jour lastActivity et expiresAt
now := time.Now().Unix()
session.LastActivity = now
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
// Resauvegarder
sessionJSON, _ := json.Marshal(session)
ttl := time.Duration(session.ExpiresAt-now) * time.Second
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
return nil // Pas critique
}
return nil
}
// ============================================
// INVALIDER UNE SESSION (LOGOUT)
// ============================================
// InvalidateSession supprime la session Redis (logout)
// Appelé depuis handlers/auth.go dans LogoutClient
func (d *Database) InvalidateSession(clientID int) error {
sessionKey := fmt.Sprintf("session:client:%d", clientID)
basketKey := fmt.Sprintf("session:basket:%d", clientID)
// Supprimer la session
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
}
// Vider le panier Redis
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
}
// Retirer de l'index
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
}
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
return nil
}
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:16
container_name: gestion_postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "${DB_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- gestion-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7
container_name: gestion_redis
restart: unless-stopped
command:
[
"redis-server",
"--appendonly",
"yes",
"--requirepass",
"${REDIS_PASSWORD}",
]
ports:
- "${REDIS_PORT}:6379"
volumes:
- redis_data:/data
networks:
- gestion-net
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
gestion-net:
volumes:
postgres_data:
redis_data:
+79
View File
@@ -0,0 +1,79 @@
module gestion
go 1.24.4
require (
github.com/gabriel-vasile/mimetype v1.4.9
github.com/gin-contrib/cors v1.7.6
github.com/gin-contrib/sessions v1.0.4
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.0
golang.org/x/crypto v0.40.0
golang.org/x/text v0.27.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
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/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+172
View File
@@ -0,0 +1,172 @@
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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+77
View File
@@ -0,0 +1,77 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"github.com/gin-gonic/gin"
)
func AddAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
var req struct {
CorrectAddress string `json:"correct_address" binding:"required"`
InvalidAddress string `json:"invalid_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
if err := database.AddAddress(req.CorrectAddress, req.InvalidAddress); err != nil {
utils.ServerErr(c, "Impossible d'ajouter l'adresse", err)
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Adresses ajoutées avec succès"})
}
func DeleteAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
var req struct {
CorrectAddress string `json:"correct_address" binding:"required"`
InvalidAddress string `json:"invalid_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
if err := database.DeleteAddress(req.InvalidAddress, req.CorrectAddress); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'adresse", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Adresses supprimées avec succès"})
}
func GetAllAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
getAddress, err := database.AllAddress()
if err != nil {
utils.ServerErr(c, "Impossible de récupérer les adresses", err)
return
}
c.JSON(http.StatusOK, gin.H{"addresses": getAddress})
}
+250
View File
@@ -0,0 +1,250 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func AlertPolice(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
var req struct {
Message string `json:"message"`
}
_ = c.ShouldBindJSON(&req)
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr, req.Message)
if err != nil {
utils.ServerErr(c, "Impossible de créer l'alerte", err)
return
}
go database.NotifyAllAdminCabineAlert(alert.ID, usernameStr, req.Message)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Police alert created",
"alert_id": alert.ID,
"user": alert.Username,
})
}
func DeleteAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
// 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 {
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Alert deleted",
})
}
func GetAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" && userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
// 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{
"success": true,
"alert": alert,
})
}
// EndAlert permet au livreur de mettre fin à son alerte active
func EndAlert(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
alertIDStr := c.Param("id")
alertID, err := strconv.Atoi(alertIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'alerte invalide"})
return
}
usernameStr := username.(string)
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != usernameStr {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
if err = database.EndAlert(alertID); err != nil {
utils.ServerErr(c, "Impossible de mettre fin à l'alerte", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Alerte terminée avec succès",
"alert_id": alertID,
"user": usernameStr,
})
}
func GetMyAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
userRole := c.GetString("role")
if userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
alerts, err := database.GetAlertsByUsername(usernameStr)
if err != nil {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
"user": usernameStr,
})
}
func GetAllAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
alerts, err := database.GetAllAlerts()
if err != nil {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
func GetActiveAlerts(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
alerts, err := database.GetActiveAlerts()
if err != nil {
utils.ServerErr(c, "Impossible de récupérer les alertes", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"alerts": alerts,
"count": len(alerts),
})
}
+707
View File
@@ -0,0 +1,707 @@
package handlers
import (
"crypto/rand"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
clientTokenDuration = 5 * time.Hour
adminTokenDuration = 10 * time.Hour
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET"))
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET"))
)
func generateClientToken(client *models.Client) (string, error) {
sessionID := utils.GenerateSessionID()
claims := models.ClientClaims{
ClientID: client.ID,
Username: client.Username,
Role: "client",
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(clientTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-client",
Subject: strconv.Itoa(client.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(userJWTSecret)
if err != nil {
return "", err
}
return tokenString, nil
}
func generateAdminToken(user *models.User) (string, error) {
sessionID := utils.GenerateSessionID()
claims := models.AdminClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin",
Subject: strconv.Itoa(user.ID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(adminJWTSecret)
if err != nil {
return "", err
}
return tokenString, nil
}
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
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
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Binding error: %v | body: username=%q nom=%q prenom=%q tel=%q", err, req.Username, req.Nom, req.Prenom, req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides", "detail": err.Error()})
return
}
// Sanitize text inputs
req.Username = utils.StripHTML(req.Username)
req.Nom = utils.StripHTML(req.Nom)
req.Prenom = utils.StripHTML(req.Prenom)
if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Téléphone invalide: %q", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
database := c.MustGet("database").(*db.Database)
if existing, _ := database.GetClientByUsername(req.Username); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
if existing, _ := database.GetClientByTelephone(normalizedPhone); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client := &models.Client{
Username: req.Username,
Password: string(hashed),
Nom: strings.TrimSpace(req.Nom),
Prenom: strings.TrimSpace(req.Prenom),
Telephone: normalizedPhone,
MustChangePassword: true,
}
if err := database.CreateClient(client); err != nil {
log.Printf("❌ [ADMIN_CREATE_CLIENT] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Client créé avec succès",
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
},
})
}
func cryptoRandInt() int {
b := make([]byte, 4)
rand.Read(b)
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
}
// LoginClient authentifie un client
func LoginClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
client, err := database.GetClientByUsername(req.Username)
if err != nil || client == nil {
log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur récupération des paramètres: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
if settings.Telegram2FAEnabled && client.TwoFAEnabled {
chatID, linked, _ := database.GetClientTelegramChatID(client.Username)
if linked {
code := fmt.Sprintf("%06d", cryptoRandInt()%1000000)
sessionToken := uuid.New().String()
if err := db.Store2FASession(sessionToken, client.Username, code); err != nil {
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)
if err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
log.Printf("❌ [LOGIN_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [LOGIN_CLIENT] Erreur session Redis: %v", err)
}
c.JSON(http.StatusOK, models.LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(clientTokenDuration.Seconds()),
User: gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"role": "client",
"session_id": sessionID,
"must_change_password": client.MustChangePassword,
},
})
}
func Verify2FAClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
SessionToken string `json:"session_token" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
username, err := db.Verify2FASession(req.SessionToken, req.Code)
if err != nil {
log.Printf("❌ [2FA] Échec vérification: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
client, err := database.GetClientByUsername(username)
if err != nil || client == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne"})
return
}
token, err := generateClientToken(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [2FA] Erreur session Redis: %v", err)
}
c.JSON(http.StatusOK, models.LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(clientTokenDuration.Seconds()),
User: gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"role": "client",
"session_id": sessionID,
"must_change_password": client.MustChangePassword,
},
})
}
func GetClient2FAStatus(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
_, tgLinked, _ := database.GetClientTelegramChatID(client.Username)
settings, _ := database.GetSettings()
c.JSON(http.StatusOK, gin.H{
"two_fa_enabled": client.TwoFAEnabled,
"telegram_linked": tgLinked,
"admin_2fa_enabled": settings.Telegram2FAEnabled,
})
}
func ToggleClient2FA(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
client, err := database.GetClientByID(clientID)
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
if req.Enabled {
_, linked, _ := database.GetClientTelegramChatID(client.Username)
if !linked {
c.JSON(http.StatusBadRequest, gin.H{"error": "Telegram non lié — impossible d'activer la 2FA"})
return
}
settings, _ := database.GetSettings()
if !settings.Telegram2FAEnabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "La 2FA n'est pas activée par l'administrateur"})
return
}
}
if err := database.SetClientTwoFAEnabled(clientID, req.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "two_fa_enabled": req.Enabled})
}
// ChangePassword permet à un client de changer son mot de passe
func ChangePassword(c *gin.Context) {
var req struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Password), []byte(req.CurrentPassword)); err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Mot de passe actuel invalide: ID=%d", clientID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Mot de passe actuel incorrect"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
if err := database.UpdateClientPasswordAndClearFlag(clientID, string(hashed)); err != nil {
log.Printf("❌ [CHANGE_PASSWORD] Erreur update: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour mot de passe"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Mot de passe mis à jour avec succès"})
}
// LogoutClient déconnecte un client
func LogoutClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
clientID, hasClientID := c.Get("client_id")
if hasClientID && clientID != nil {
if err := database.InvalidateSession(clientID.(int)); err != nil {
log.Printf("⚠️ [LOGOUT_CLIENT] Erreur invalidation session: %v", err)
}
}
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// LoginAdmin authentifie un admin/cabine/livreur
func LoginAdmin(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByUsername(req.Username)
if err != nil || user == nil {
log.Printf("❌ [LOGIN_ADMIN] User non trouvé: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if user.Role != "admin" && user.Role != "cabine" && user.Role != "livreur" {
log.Printf("❌ [LOGIN_ADMIN] Rôle invalide: %s", user.Role)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Accès non autorisé"})
return
}
if user.Password == "" {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe vide en base")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Mot de passe invalide: %s", req.Username)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"})
return
}
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)
expiresAt := time.Now().Add(adminTokenDuration)
if err := database.SaveToken(user.ID, user.Role, token, expiresAt); err != nil {
log.Printf("❌ [LOGIN_ADMIN] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
c.JSON(http.StatusOK, models.LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(adminTokenDuration.Seconds()),
User: gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
})
}
// LogoutAdmin déconnecte un admin/cabine/livreur
func LogoutAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Révoquer le JWT token
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database.RevokeToken(tokenStr)
}
log.Printf("✅ [LOGOUT_ADMIN] User déconnecté")
c.JSON(http.StatusOK, gin.H{"message": "Déconnexion réussie"})
}
// ============================================
// HELPERS
// ============================================
// GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
users, err := database.GetAllUsers()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
func GetAllDeliveryMen(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
users, err := database.GetAllDeliveryMen()
if err != nil {
log.Printf("❌ [GET_ALL_USERS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, u := range users {
sanitized = append(sanitized, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
})
}
log.Printf("✅ [GET_ALL_USERS] %d users récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"users": sanitized,
"count": len(sanitized),
})
}
// GetAllClients récupère tous les clients
// GET /api/v1/admin/clients
func GetAllClients(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
clients, err := database.GetAllClients()
if err != nil {
log.Printf("❌ [GET_ALL_CLIENTS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération"})
return
}
var sanitized []gin.H
for _, cl := range clients {
sanitized = append(sanitized, gin.H{
"id": cl.ID,
"username": cl.Username,
"nom": cl.Nom,
"prenom": cl.Prenom,
"telephone": cl.Telephone,
"command": cl.Command,
"points_extra": cl.PointsExtra,
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
"referral_balance": cl.ReferralBalance,
})
}
log.Printf("✅ [GET_ALL_CLIENTS] %d clients récupérés", len(sanitized))
c.JSON(http.StatusOK, gin.H{
"clients": sanitized,
"count": len(sanitized),
})
}
func DeleteUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_USER] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err = database.DeleteUser(id)
if err != nil {
log.Printf("❌ [DELETE_USER] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_USER] Utilisateur %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Utilisateur supprimé"})
}
// DeleteClient
func DeleteClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs et aux administrateurs"})
return
}
err = database.DeleteClient(id)
if err != nil {
log.Printf("❌ [DELETE_CLIENT] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return
}
log.Printf("✅ [DELETE_CLIENT] Client %d supprimé", id)
c.JSON(http.StatusOK, gin.H{"message": "Client supprimé"})
}
func CreateUser(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var user models.User
if err := c.ShouldBindJSON(&user); err != nil {
log.Printf("❌ [CREATE_USER] Erreur de liaison JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Erreur de liaison JSON"})
return
}
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Seul un administrateur peut créer des utilisateurs"})
return
}
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 {
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)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création"})
return
}
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
}
func Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
+165
View File
@@ -0,0 +1,165 @@
package handlers
import (
"gestion/db"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func GetLivreurPosition(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
livreurUsername := c.Param("username")
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Réservé aux administrateurs et cabines",
})
return
}
if livreurUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
return
}
position, err := database.GetLivreurPosition(livreurUsername)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"livreur": livreurUsername,
"message": "Position non disponible (GPS désactivé ou livraison terminée)",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"livreur": livreurUsername,
"position": position,
})
}
func GetDeliveryIssues(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
status := c.Query("status")
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès refusé - Réservé aux administrateurs et cabines",
})
return
}
issues, err := database.GetDeliveryIssues(status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération problèmes",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"issues": issues,
"count": len(issues),
})
}
func CreateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
CommandID int `json:"command_id" binding:"required"`
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
issue, err := database.CreateDeliveryIssue(
req.CommandID,
req.IssueType,
req.Description,
cabineUsername.(string),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur création problème",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Problème enregistré",
"issue": issue,
})
}
func UpdateDeliveryIssue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
issueID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Status string `json:"status"`
Resolution string `json:"resolution"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
cabineUsername, _ := c.Get("username")
err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Problème mis à jour",
})
}
func GetCommandLogs(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
return
}
logs, err := database.GetCommandLogs(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération logs",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"logs": logs,
"count": len(logs),
})
}
+409
View File
@@ -0,0 +1,409 @@
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var (
cancelRateLimitMap = make(map[string][]time.Time)
cancelMaxRequests = 5 // Max 5 annulations
cancelTimeWindow = time.Hour // Par heure
)
func checkCancelRateLimit(key string) bool {
now := time.Now()
if timestamps, exists := cancelRateLimitMap[key]; exists {
var validTimestamps []time.Time
for _, ts := range timestamps {
if now.Sub(ts) < cancelTimeWindow {
validTimestamps = append(validTimestamps, ts)
}
}
cancelRateLimitMap[key] = validTimestamps
if len(validTimestamps) >= cancelMaxRequests {
return false
}
}
cancelRateLimitMap[key] = append(cancelRateLimitMap[key], now)
return true
}
func validateReason(reason string) string {
if len(reason) > 500 {
reason = reason[:500]
}
reason = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, reason)
if strings.TrimSpace(reason) == "" {
return "Annulation par le client"
}
return reason
}
func CancelCommandByClient(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil || c.GetString("role") != "client" {
log.Printf("❌ [CANCEL_CLIENT] Accès refusé")
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
return
}
rateLimitKey := fmt.Sprintf("cancel:%s", username)
if !checkCancelRateLimit(rateLimitKey) {
log.Printf("⚠️ [CANCEL_CLIENT] Rate limit dépassé pour %s", username)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop d'annulations récentes",
"message": "Veuillez attendre avant de réessayer",
})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
Reason string `json:"reason"`
Force bool `json:"force"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Reason = "Annulation par le client"
req.Force = false
}
req.Reason = validateReason(req.Reason)
penalty, err := database.CancelCommandAtomic(commandID, username, req.Reason, req.Force)
if err != nil {
log.Printf("❌ [CANCEL_CLIENT] Erreur: %v", err)
if err.Error() == "confirmation requise" {
command, errCmd := database.GetCommandByID(commandID)
if errCmd != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
currentStatus, _ := command["status"].(string)
hasETA := false
if livreurAssign != "" {
hasETA = database.CheckCommandETAExistsAndValid(commandID)
}
nextPenalty, _ := database.CalculateCancellationPenalty(username)
cancelCount, _ := database.GetClientCancellationsCount(username)
response := gin.H{
"success": false,
"warning": true,
"command_info": gin.H{
"command_id": commandID,
"status": currentStatus,
"livreur": livreurAssign,
},
}
if hasETA {
log.Printf("⚠️ [CANCEL_CLIENT] Annulation tardive avec ETA - Status: %s, Livreur: %s", currentStatus, livreurAssign)
response["message"] = "⚠️ Un livreur est en route vers votre adresse (ETA définie)"
response["details"] = gin.H{
"livreur": livreurAssign,
"status": currentStatus,
"has_eta": true,
}
response["penalty_warning"] = gin.H{
"will_apply": true,
"penalty_amount": nextPenalty,
"current_violations": cancelCount,
"message": fmt.Sprintf(
"⚠️ ATTENTION: Une amende de %d sera appliquée pour annulation tardive",
nextPenalty,
),
"scale": gin.H{
"1st_cancel": 20,
"2nd_cancel": 50,
"3rd_cancel": 100,
"4th+_cancel": 150,
},
}
} else {
log.Printf("️ [CANCEL_CLIENT] Livreur assigné mais pas d'ETA - Annulation sans pénalité")
response["message"] = "️ Un livreur est assigné mais n'est pas encore en route"
response["details"] = gin.H{
"livreur": livreurAssign,
"status": currentStatus,
"has_eta": false,
}
response["penalty_warning"] = gin.H{
"will_apply": false,
"message": "Aucune pénalité ne sera appliquée car le livreur n'est pas encore en route",
"points_safe": true,
}
}
// ✅ AJOUTER LES INSTRUCTIONS D'ACTION
response["action_required"] = "Pour confirmer l'annulation, renvoyez la même requête avec 'force': true"
response["example"] = gin.H{
"reason": req.Reason,
"force": true,
}
// ✅ AJOUTER POSITION DANS LA QUEUE (si disponible)
if livreurAssign != "" {
position, posErr := database.GetCommandPositionInQueue(livreurAssign, commandID)
if posErr == nil && position > 0 {
response["details"].(gin.H)["position_in_queue"] = position
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
if queueInfo != nil {
response["details"].(gin.H)["queue_info"] = queueInfo
}
}
}
log.Printf("⚠️ [CANCEL_CLIENT] Confirmation requise pour cmd %d - hasETA=%v, penalty=%d",
commandID, hasETA, nextPenalty)
c.JSON(http.StatusConflict, response)
return
}
switch err.Error() {
case "commande non trouvée":
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
case "commande ne vous appartient pas":
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous appartient pas"})
case "impossible d'annuler":
c.JSON(http.StatusBadRequest, gin.H{"error": "Cette commande ne peut plus être annulée"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible d'annuler la commande"})
}
return
}
log.Printf("✅ [CANCEL_CLIENT] Commande %d annulée", commandID)
response := gin.H{
"success": true,
"message": "Commande annulée avec succès",
"command_id": commandID,
"new_status": "cancelled",
}
if penalty > 0 {
response["penalty"] = gin.H{
"penalty_amount": penalty,
"warning": "Une amende a été appliquée pour annulation tardive",
}
} else {
response["info"] = "Aucune pénalité appliquée"
}
c.JSON(http.StatusOK, response)
}
func GetMyCancellationHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil || c.GetString("role") != "client" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux clients"})
return
}
log.Printf("📊 [CANCEL_HISTORY] Client %s - Consultation historique", username)
history, err := database.GetClientCancellationHistory(username)
if err != nil {
log.Printf("❌ [CANCEL_HISTORY] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération historique",
})
return
}
var penaltyResult struct {
Amende int `gorm:"column:amende"`
}
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{
"success": true,
"data": gin.H{
"username": username,
"history": history,
"total_penalties": totalPenalty,
},
})
}
func GetAllCancelledOrders(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
log.Printf("❌ [GET_ALL_CANCELLED] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
filterUsername := c.Query("username")
if len(filterUsername) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username trop long"})
return
}
limitStr := c.DefaultQuery("limit", "50")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
limit = 50
}
if limit > 500 {
limit = 500
}
log.Printf("📋 [GET_ALL_CANCELLED] %s (%s) récupère %d commandes", username, userRole, limit)
cancelledOrders, err := database.GetCancelledCommands(filterUsername, limit)
if err != nil {
log.Printf("❌ [GET_ALL_CANCELLED] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération",
})
return
}
var enrichedOrders []map[string]any
for _, order := range cancelledOrders {
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
items, _ := database.GetCommandItems(orderID)
logs, _ := database.GetCommandLogs(orderID)
var cancellationLog map[string]any
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
if status == "cancelled" {
cancellationLog = logEntry
break
}
}
cancelReason, _ := order["cancel_reason"].(string)
enrichedOrder := map[string]any{
"id": order["id"],
"username": order["username"],
"total_prix": order["total_prix"],
"created_at": order["created_at"],
"updated_at": order["updated_at"],
"items_count": len(items),
"cancel_reason": cancelReason,
}
if cancellationLog != nil {
enrichedOrder["cancellation"] = gin.H{
"cancelled_at": cancellationLog["created_at"],
"cancelled_by": cancellationLog["author"],
"reason": cancelReason,
}
}
enrichedOrders = append(enrichedOrders, enrichedOrder)
}
log.Printf("✅ [GET_ALL_CANCELLED] %d commandes récupérées", len(enrichedOrders))
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"cancelled_orders": enrichedOrders,
"count": len(enrichedOrders),
},
})
}
func DeleteCommandByCabine(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
userRole := c.GetString("role")
if userRole != "cabine" && userRole != "admin" {
log.Printf("❌ [DELETE_COMMAND] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux cabines"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
log.Printf("🗑️ [DELETE_COMMAND] %s (%s) supprime cmd %d", username, userRole, commandID)
err = database.DeleteCommandAtomic(commandID, username, userRole)
if err != nil {
log.Printf("❌ [DELETE_COMMAND] Erreur: %v", err)
if err.Error() == "commande non trouvée" {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la suppression"})
}
return
}
log.Printf("✅ [DELETE_COMMAND] Commande %d supprimée", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande supprimée définitivement",
"deleted_by": gin.H{
"username": username,
"role": userRole,
},
})
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func GetCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
categories, err := database.GetAllCategories()
if err != nil {
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"categories": categories,
})
}
func CreateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
return
}
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
return
}
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"category": category,
})
}
// PUT /api/v2/admin/protected/categories/:id — admin
func UpdateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Couleur invalide (format hex requis, ex: #ff0000)"})
return
}
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
return
}
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
c.JSON(http.StatusOK, gin.H{
"success": true,
"category": category,
})
}
func 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) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.DeleteCategory(id); err != nil {
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
if strings.Contains(err.Error(), "utilisée par") {
c.JSON(http.StatusConflict, gin.H{"error": "Catégorie utilisée par des produits existants"})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
}
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
}
+241
View File
@@ -0,0 +1,241 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetCommandStatus - Statut temps réel d'une commande
func GetCommandStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
cmdUsername, _ := command["username"].(string)
if cmdUsername != usernameStr {
log.Printf("❌ [STATUS] Accès refusé - cmd de %s demandée par %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
etaData, _ := database.GetCommandETA(commandID)
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := command["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
statusMessage := getStatusMessage(command["status"].(string))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": statusMessage,
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"livreur": livreurInfo,
"eta": etaData,
})
}
// GetMyCommandsWithTracking - Liste des commandes avec suivi
func GetMyCommandsWithTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
usernameStr := username.(string)
status := c.Query("status")
log.Printf("📋 [MY_CMDS] Client %s demande ses commandes (status=%s)", usernameStr, status)
commands, err := database.GetAllCommands(status, usernameStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
})
return
}
// Enrichir avec tracking
enrichedCommands := make([]gin.H, len(commands))
for i, cmd := range commands {
commandID, _ := cmd["id"].(int)
// ETA
etaData, _ := database.GetCommandETA(commandID)
// Infos livreur
livreurInfo := gin.H{
"assigned": false,
}
if livreurAssign, ok := cmd["livreur_assign"].(string); ok && livreurAssign != "" {
queueInfo, _ := database.GetDeliverymanQueueInfo(livreurAssign)
livreurInfo = gin.H{
"assigned": true,
"livreur_name": livreurAssign,
"queue_position": queueInfo["queue_size"],
}
}
enrichedCommands[i] = gin.H{
"id": cmd["id"],
"client_order_number": cmd["client_order_number"],
"status": cmd["status"],
"status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"],
"livreur": livreurInfo,
"eta": etaData,
"proposed_address": cmd["proposed_address"],
"address_proposal_status": cmd["address_proposal_status"],
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
})
}
// GetCommandTracking - Suivi détaillé d'une commande
func GetCommandTracking(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// Récupérer commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
cmdUsername, _ := command["username"].(string)
if cmdUsername != username {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
logs, _ := database.GetCommandLogs(commandID)
etaData, _ := database.GetCommandETA(commandID)
timeline := buildTimeline(logs)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": command["status"],
"status_message": getStatusMessage(command["status"].(string)),
"eta": etaData,
"timeline": timeline,
"logs": logs,
})
}
func getStatusMessage(status string) string {
messages := map[string]string{
"pending": "⏳ En attente d'assignation",
"assigned": "✅ Livreur assigné",
"en_route": "🚗 En cours de livraison",
"arrived": "📍 Livreur arrivé",
"livre": "📦 Livré - En attente de confirmation",
"delivered": "✅ Livré",
"approved": "🎉 Livraison confirmée",
"cancelled": "🚫 Annulée",
"disabled": "⚠️ Désactivée",
}
if msg, ok := messages[status]; ok {
return msg
}
return "📋 " + status
}
// buildTimeline construit une timeline depuis les logs
func buildTimeline(logs []map[string]any) []gin.H {
timeline := make([]gin.H, 0)
for _, logEntry := range logs {
status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string)
createdAt := logEntry["created_at"]
timeline = append(timeline, gin.H{
"status": status,
"message": message,
"icon": getStatusIcon(status),
"created_at": createdAt,
})
}
return timeline
}
// getStatusIcon retourne une icône pour la timeline
func getStatusIcon(status string) string {
icons := map[string]string{
"created": "🛒",
"assigned": "👤",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
"approved": "🎉",
"cancelled": "🚫",
}
if icon, ok := icons[status]; ok {
return icon
}
return "📋"
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
package handlers
import (
"encoding/json"
"gestion/db"
"gestion/services"
"io"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// IPNWebhook - POST /api/v1/webhooks/nowpayments
func IPNWebhook(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
npRaw, npExists := c.Get("nowpayments")
np, ok := npRaw.(*services.NowPaymentsClient)
if !npExists || !ok || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "impossible de lire le corps"})
return
}
sig := c.GetHeader("x-nowpayments-sig")
if !np.VerifyIPN(body, sig) {
log.Printf("[IPN] signature invalide")
c.JSON(http.StatusUnauthorized, gin.H{"error": "signature invalide"})
return
}
var payload services.IPNPayload
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "payload invalide"})
return
}
payment, err := database.GetCryptoPaymentByNowPaymentID(payload.PaymentID.String())
if err != nil || payment == nil {
log.Printf("[IPN] paiement introuvable: %s", payload.PaymentID.String())
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
payAmount, _ := payload.PayAmount.Float64()
if err := database.UpdateCryptoPaymentStatus(payment.ID, payload.PaymentStatus, payAmount); err != nil {
log.Printf("[IPN] erreur mise à jour paiement %d: %v", payment.ID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur base de données"})
return
}
switch payload.PaymentStatus {
case "finished", "confirmed":
if err := database.ActivateCryptoCommand(payment.CommandID); err != nil {
log.Printf("[IPN] erreur activation commande %d: %v", payment.CommandID, err)
} else {
log.Printf("[IPN] commande %d activée (paiement %s confirmé)", payment.CommandID, payload.PaymentID.String())
}
case "failed", "expired":
if err := database.CancelCryptoCommand(payment.CommandID); err != nil {
log.Printf("[IPN] erreur annulation commande %d: %v", payment.CommandID, err)
} else {
log.Printf("[IPN] commande %d annulée (paiement %s)", payment.CommandID, payload.PaymentStatus)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetCommandPaymentStatus - GET /api/v1/commands/:id/payment-status
// Retourne le statut du paiement crypto d'une commande (polling côté client)
// Effectue un refresh temps réel depuis NowPayments si le paiement est encore en attente
func GetCommandPaymentStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "id de commande invalide"})
return
}
payment, err := database.GetCryptoPaymentByCommandID(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur base de données"})
return
}
if payment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "aucun paiement crypto pour cette commande"})
return
}
// Refresh temps réel depuis NowPayments pour les statuts intermédiaires (comme la référence)
switch payment.Status {
case "waiting", "confirming", "confirmed", "sending":
np, npOk := c.Get("nowpayments")
if npOk && np != nil {
npClient := np.(*services.NowPaymentsClient)
npStatus, err := npClient.GetPaymentStatus(payment.NowPaymentID)
if err == nil && npStatus.PaymentStatus != payment.Status {
payAmount, _ := npStatus.PayAmount.Float64()
if updateErr := database.UpdateCryptoPaymentStatus(payment.ID, npStatus.PaymentStatus, payAmount); updateErr == nil {
payment.Status = npStatus.PaymentStatus
payment.PayAmount = payAmount
log.Printf("[PAYMENT-STATUS] cmd %d: %s → %s (refresh temps réel)", commandID, payment.Status, npStatus.PaymentStatus)
}
switch npStatus.PaymentStatus {
case "finished", "confirmed":
_ = database.ActivateCryptoCommand(commandID)
case "failed", "expired":
_ = database.CancelCryptoCommand(commandID)
}
}
}
}
c.JSON(http.StatusOK, gin.H{
"command_id": payment.CommandID,
"payment_status": payment.Status,
"pay_address": payment.PayAddress,
"pay_amount": payment.PayAmount,
"pay_currency": payment.PayCurrency,
"price_amount": payment.PriceAmount,
"price_currency": payment.PriceCurrency,
})
}
+625
View File
@@ -0,0 +1,625 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"slices"
"strconv"
"github.com/gin-gonic/gin"
)
func GetMyDeliveries(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
status := c.Query("status")
commands, err := database.GetDeliveryPersonCommands(usernameStr, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération",
})
return
}
// 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))
for i, cmd := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
items := allItems[commandID]
clientUsername, _ := cmd["username"].(string)
client := allClients[clientUsername]
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for j, item := range items {
itemsSummary[j] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"is_reward": item["is_reward"],
}
}
etaData, _ := database.GetCommandETA(commandID)
filteredCommands[i] = gin.H{
"id": cmd["id"],
"status": cmd["status"],
"adresse": cmd["adresse"],
"total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
}
}
log.Printf("✅ [MY_DELIVERIES] %d livraisons (données filtrées)", len(filteredCommands))
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": filteredCommands,
"count": len(filteredCommands),
})
}
// ============================================
// GetDeliveryDetails
// ============================================
func GetDeliveryDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// ✅ VÉRIFIER PROPRIÉTÉ
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username {
log.Printf("❌ Accès refusé - cmd assignée à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette livraison ne vous est pas assignée",
})
return
}
items, _ := database.GetCommandItems(commandID)
clientUsername, _ := command["username"].(string)
client, _ := database.GetClientByUsername(clientUsername)
clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil {
clientInfo = gin.H{
"nom": client.Nom,
"prenom": client.Prenom,
}
}
itemsSummary := make([]gin.H, len(items))
for i, item := range items {
itemsSummary[i] = gin.H{
"produit": item["produit"],
"quantite": item["quantite"],
"prix": item["prix"],
"is_reward": item["is_reward"],
}
}
etaData, _ := database.GetCommandETA(commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"delivery": gin.H{
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"referral_used": command["referral_used"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
},
})
}
func UpdateDeliveryStatus(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Status string `json:"status" binding:"required"`
Notes string `json:"notes"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
log.Printf("📝 [UPD_STATUS] %s update cmd %d: %s", usernameStr, commandID, req.Status)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
log.Printf("❌ Accès refusé - assigné à %s", livreurAssign)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
validStatuses := []string{
"assigned",
"en_route",
"arrived",
"livre",
"cancelled",
}
if !slices.Contains(validStatuses, req.Status) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
if req.Status == "livre" {
if req.Latitude == 0 || req.Longitude == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Coordonnées GPS requises pour confirmer la livraison"})
return
}
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
if destLat != 0 && destLon != 0 {
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance)
log.Printf("✅ [GPS] Validation OK")
} else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
}
}
// Mettre à jour le statut.
// 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{
"error": "Erreur mise à jour",
})
return
}
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
var etaMinutes int
var etaMessage string
if req.Status == "en_route" {
log.Printf("🚗 [STATUS_LIVREUR] Passage en 'en_route' - Calcul ETA...")
var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
destLat = coords.Lat
destLon = coords.Lon
log.Printf("📍 [STATUS_LIVREUR] Coords depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
// 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := command["dest_longitude"].(float64); ok && dLon != 0 {
destLon = dLon
}
if destLat != 0 && destLon != 0 {
log.Printf("📍 [STATUS_LIVREUR] Coords depuis DB: (%.6f, %.6f)", destLat, destLon)
}
}
if destLat != 0 && destLon != 0 {
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Cas 1 : GPS du livreur disponible
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 {
// 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)
if etaMinutes >= 60 {
h := etaMinutes / 60
m := etaMinutes % 60
if m > 0 {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh%02d", h, m)
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %dh", h)
}
} else {
etaMessage = fmt.Sprintf("Arrivée prévue dans %d minutes", etaMinutes)
}
// Mettre à jour le statut du livreur en "delivering"
database.SetDeliveryPersonStatus(usernameStr, "delivering", commandID)
log.Printf("🚗 [STATUS_LIVREUR] Statut livreur mis à jour: delivering")
}
// Log
message := req.Notes
if message == "" {
message = utils.GetDeliveryStatusMessage(req.Status)
}
if etaMessage != "" {
message += fmt.Sprintf(" - %s", etaMessage)
}
database.AddCommandLog(commandID, req.Status, message, usernameStr)
// ✅ NOTIFICATION CLIENT
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
var clientMsg string
switch req.Status {
case "en_route":
notifETA := etaMinutes
if notifETA == 0 {
if etaData, err := database.GetCommandETA(commandID); err == nil {
if v, ok := etaData["total_eta_minutes"]; ok {
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
notifETA = n
}
}
}
}
if notifETA > 0 {
var etaStr string
if notifETA >= 60 {
h := notifETA / 60
m := notifETA % 60
if m > 0 {
etaStr = fmt.Sprintf("%dh%02d", h, m)
} else {
etaStr = fmt.Sprintf("%dh", h)
}
} else {
etaStr = fmt.Sprintf("%d min", notifETA)
}
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
} else {
clientMsg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
}
case "arrived":
clientMsg = fmt.Sprintf("Descend, le livreur est là dans 3min (commande #%d) 🛵", database.GetClientOrderID(commandID))
case "livre":
clientMsg = fmt.Sprintf("Ta commande #%d a bien été livrée ! Bonne dégustation l'ami et à bientôt 😊\n\n<b>⚠️ VALIDE LA RÉCEPTION DE TA COMMANDE DANS LA RUBRIQUE SUIVI POUR RÉCUPÉRER TES POINTS DE FIDÉLITÉ ⚠️</b>", database.GetClientOrderID(commandID))
case "cancelled":
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", database.GetClientOrderID(commandID))
}
if clientMsg != "" {
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
}
}
// ✅ GESTION SPÉCIALE SELON LE STATUT
switch req.Status {
case "livre":
// Livraison terminée - Optimiser la queue
log.Printf("📦 Livraison marquée 'livre' - Optimisation queue...")
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "cancelled":
// Transition + remboursement stock déjà effectués atomiquement plus haut.
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
response := gin.H{
"success": true,
"message": "Statut mis à jour",
"command_id": commandID,
"status": req.Status,
}
if req.Status == "en_route" && etaMinutes > 0 {
response["eta_minutes"] = etaMinutes
response["eta_message"] = etaMessage
}
c.JSON(http.StatusOK, response)
}
// POST /api/v1/livreur/deliveries/:id/issue
func ReportDeliveryIssue(c *gin.Context) {
username := c.GetString("username")
if c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
IssueType string `json:"issue_type" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "issue_type requis"})
return
}
validTypes := map[string]bool{
"client_absent": true,
"wrong_address": true,
"refused_delivery": true,
"no_access": true,
"other": true,
}
if !validTypes[req.IssueType] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de problème invalide"})
return
}
database := c.MustGet("database").(*db.Database)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
return
}
if livreur, _ := command["livreur_assign"].(string); livreur != username {
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non assignée à vous"})
return
}
issue, err := database.CreateDeliveryIssue(commandID, req.IssueType, req.Description, username)
if err != nil {
log.Printf("❌ [ISSUE] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création problème"})
return
}
log.Printf("📋 [ISSUE] Créé par %s pour commande #%d: %s", username, commandID, req.IssueType)
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
}
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,
})
}
+518
View File
@@ -0,0 +1,518 @@
// ============================================
// handlers/delivery_admin_handlers.go
// HANDLERS ADMIN POUR LA GESTION DES LIVREURS
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"gestion/utils"
"log"
"net/http"
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// GetDeliveryPersonDetails récupère les détails complets d'un livreur
func GetDeliveryPersonDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_DETAILS] Livreur non trouvé: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"error": "Livreur non trouvé",
})
return
}
// Vérifier que c'est bien un livreur
if livreur.Role != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Utilisateur n'est pas un livreur: role=%s", livreur.Role)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
"role": livreur.Role,
})
return
}
status, err := database.GetDeliveryPersonStatus(username)
if err != nil {
log.Printf("⚠️ [GET_DELIVERY_DETAILS] Impossible de récupérer le statut: %v", err)
status = "offline" // Statut par défaut
}
// Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]any
if err == nil {
locationInfo = map[string]any{
"latitude": lat,
"longitude": lon,
}
}
queueSize, _ := database.GetDeliverymanQueueSize(username)
currentCommand, _ := database.GetCurrentCommand(username)
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "assigned,en_route,livre")
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"id": livreur.ID,
"username": livreur.Username,
"role": livreur.Role,
"status": status,
"current_command": currentCommand,
"queue_size": queueSize,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"pending_deliveries": pendingDeliveries,
"location": locationInfo,
},
})
}
// UpdateDeliveryPersonStatusAdmin modifie le statut d'un livreur (Admin)
func UpdateDeliveryPersonStatusAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, userRole) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut requis",
})
return
}
validStatuses := []string{"available", "busy", "offline"}
if !slices.Contains(validStatuses, req.Status) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Statut invalide",
"valid_statuses": validStatuses,
"received": req.Status,
})
return
}
log.Printf("📝 [UPDATE_DELIVERY_STATUS] Modification: %s → %s", username, req.Status)
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
err = database.UpdateDeliveryPersonStatus(username, req.Status)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_STATUS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_STATUS] Statut modifié par admin %s: %s → %s",
adminUsername, username, req.Status)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Statut du livreur mis à jour",
"username": username,
"new_status": req.Status,
"updated_by": adminUsername,
})
}
// GetDeliveryPersonStats récupère les statistiques d'un livreur
func GetDeliveryPersonStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_STATS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("📊 [GET_DELIVERY_STATS] Calcul stats pour: %s", username)
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_STATS] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer les statistiques
// ============================================
totalDeliveries, _ := database.CountDeliveriesByStatus(username, "")
completedDeliveries, _ := database.CountDeliveriesByStatus(username, "approved")
cancelledDeliveries, _ := database.CountDeliveriesByStatus(username, "cancelled")
pendingDeliveries, _ := database.CountDeliveriesByStatus(username, "pending,assigned")
inProgressDeliveries, _ := database.CountDeliveriesByStatus(username, "en_route,livre")
// Récupérer statut et queue
status, _ := database.GetDeliveryPersonStatus(username)
queueSize, _ := database.GetDeliverymanQueueSize(username)
// Calculer le taux de succès
successRate := 0.0
if totalDeliveries > 0 {
successRate = (float64(completedDeliveries) / float64(totalDeliveries)) * 100
}
// Récupérer la dernière livraison
lastDeliveryDate := ""
lastDelivery, err := database.GetLastDeliveryDate(username)
if err == nil && lastDelivery != nil {
lastDeliveryDate = lastDelivery.Format("2006-01-02 15:04:05")
}
log.Printf("✅ [GET_DELIVERY_STATS] Stats calculées: total=%d, completed=%d",
totalDeliveries, completedDeliveries)
// ============================================
// RÉPONSE
// ============================================
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": gin.H{
"username": username,
"total_deliveries": totalDeliveries,
"completed_deliveries": completedDeliveries,
"cancelled_deliveries": cancelledDeliveries,
"pending_deliveries": pendingDeliveries,
"in_progress_deliveries": inProgressDeliveries,
"success_rate": successRate,
"current_queue_size": queueSize,
"last_delivery_date": lastDeliveryDate,
"status": status,
},
})
}
// GetDeliveryPersonHistory récupère l'historique des livraisons d'un livreur
func GetDeliveryPersonHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [GET_DELIVERY_HISTORY] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
// Paramètres de pagination
limit := 20
offset := 0
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
log.Printf("📜 [GET_DELIVERY_HISTORY] Récupération historique: %s (limit=%d, offset=%d)",
username, limit, offset)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Récupérer l'historique
// ============================================
history, err := database.GetDeliveryPersonHistory(username, limit, offset)
if err != nil {
log.Printf("❌ [GET_DELIVERY_HISTORY] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération historique",
})
return
}
// Compter le total
total, _ := database.CountDeliveriesByStatus(username, "")
log.Printf("✅ [GET_DELIVERY_HISTORY] Historique récupéré: %d livraisons (total=%d)",
len(history), total)
c.JSON(http.StatusOK, gin.H{
"success": true,
"history": history,
"count": len(history),
"total": total,
"limit": limit,
"offset": offset,
})
}
// UpdateDeliveryPersonLocationAdmin modifie la position GPS d'un livreur (Admin)
// PUT /api/v2/admin/protected/delivery-persons/:username/location
func UpdateDeliveryPersonLocationAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
var req struct {
Latitude float64 `json:"latitude" binding:"required"`
Longitude float64 `json:"longitude" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
})
return
}
// Valider les coordonnées
if req.Latitude < -90 || req.Latitude > 90 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Latitude invalide (doit être entre -90 et 90)",
})
return
}
if req.Longitude < -180 || req.Longitude > 180 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Longitude invalide (doit être entre -180 et 180)",
})
return
}
log.Printf("📍 [UPDATE_DELIVERY_LOCATION] Modification: %s → (%.6f, %.6f)",
username, req.Latitude, req.Longitude)
// ============================================
// Vérifier que le livreur existe
// ============================================
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
// ============================================
// Mettre à jour la position (utilise la fonction existante)
// ============================================
err = database.UpdateDeliveryPersonLocation(username, req.Latitude, req.Longitude)
if err != nil {
log.Printf("❌ [UPDATE_DELIVERY_LOCATION] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour position",
})
return
}
adminUsername, _ := c.Get("username")
log.Printf("✅ [UPDATE_DELIVERY_LOCATION] Position modifiée par admin %s: %s → (%.6f, %.6f)",
adminUsername, username, req.Latitude, req.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Position GPS mise à jour",
"location": gin.H{
"username": username,
"latitude": req.Latitude,
"longitude": req.Longitude,
"updated_at": time.Now().Unix(),
},
"updated_by": adminUsername,
})
}
func RemoveCommandFromQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [REMOVE_FROM_QUEUE] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
commandIDStr := c.Param("command_id")
if username == "" || commandIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Username et command_id requis",
})
return
}
commandID, err := strconv.Atoi(commandIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("🗑️ [REMOVE_FROM_QUEUE] Suppression: cmd %d de la queue de %s", commandID, username)
livreur, err := database.GetUserByUsername(username)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Livreur non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Livreur non trouvé"})
return
}
if livreur.Role != "livreur" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cet utilisateur n'est pas un livreur",
})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
err = database.RemoveCommandFromDeliverymanQueue(username, commandID)
if err != nil {
log.Printf("❌ [REMOVE_FROM_QUEUE] Erreur suppression: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur suppression de la queue",
})
return
}
currentStatus, _ := command["status"].(string)
if currentStatus == "assigned" || currentStatus == "en_route" {
err = database.UpdateCommandStatus(commandID, "pending")
if err != nil {
log.Printf("⚠️ [REMOVE_FROM_QUEUE] Impossible de réinitialiser le statut: %v", err)
} else {
database.UpdateCommandLivreur(commandID, "")
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande réinitialisée en 'pending'")
}
}
adminUsername, _ := c.Get("username")
database.AddCommandLog(commandID, "queue_removed",
fmt.Sprintf("Commande retirée de la queue du livreur %s par admin %s", username, adminUsername),
adminUsername.(string))
log.Printf("✅ [REMOVE_FROM_QUEUE] Commande %d retirée de la queue de %s", commandID, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande retirée de la queue du livreur",
"command_id": commandID,
"username": username,
"removed_by": adminUsername,
})
}
+293
View File
@@ -0,0 +1,293 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"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) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
// 1️⃣ AUTHENTIFICATION
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [ETA] Non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": "Utilisateur non authentifié",
})
return
}
// 2️⃣ RÉCUPÉRER L'ID DE LA COMMANDE
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("❌ [ETA] ID invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "ID de commande invalide",
})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ETA] Commande %d non trouvée", commandID)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Commande non trouvée",
})
return
}
cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole == "client" && cmdUsername != username.(string) {
log.Printf("❌ [ETA] Accès refusé - commande appartient à %s", cmdUsername)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
if userRole == "livreur" {
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username.(string) {
log.Printf("❌ [ETA] Accès refusé - livreur non assigné")
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"error": "Vous n'avez pas accès à cette commande",
})
return
}
}
}
cmdStatus, _ := command["status"].(string)
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "La commande a déjà été livrée",
"eta_available": false,
"estimated_arrival": "Livraison complétée",
})
return
}
if cmdStatus == "pending" || cmdStatus == "assigned" {
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_available": false,
"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
}
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
if err == nil && len(etaData) > 0 {
// ETA existe, vérifier s'il est récent
if updatedAtStr, ok := etaData["updated_at"]; ok {
var updatedAt int64
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
if timeSinceUpdate < 30*time.Second {
// Cache valide
var etaMinutes int64
if etaStr, ok := etaData["eta_minutes"]; ok {
fmt.Sscanf(etaStr, "%d", &etaMinutes)
}
var arrivalTime int64
if arrivalStr, ok := etaData["arrival_time"]; ok {
fmt.Sscanf(arrivalStr, "%d", &arrivalTime)
}
log.Printf("✅ [ETA] Cache hit - ETA: %d min", etaMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"estimated_arrival": time.Unix(arrivalTime, 0).Format("15:04"),
"eta_available": true,
"with_traffic": true,
"livreur_assign": command["livreur_assign"],
"delivery_address": command["adresse"],
})
return
}
}
}
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
destLat = coords.Lat
destLon = coords.Lon
log.Printf("📍 Destination depuis cache Redis: (%.6f, %.6f)", destLat, destLon)
}
}
if destLat == 0 || destLon == 0 {
if dLat, okLat := command["dest_latitude"].(float64); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := command["dest_longitude"].(float64); okLon && dLon != 0 {
destLon = dLon
}
}
if destLat == 0 || destLon == 0 {
log.Printf("⚠️ [ETA] Coordonnées destination manquantes - retour cache périmé ou message")
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
return
}
// Récupérer position du livreur
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign == "" {
log.Printf("⚠️ [ETA] Aucun livreur assigné")
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_available": false,
"message": "Aucune heure disponible",
})
return
}
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
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)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
etaMinutes, distanceKm, err := services.GetETAWithTraffic(*livreurLocation, toCoords)
if err != nil {
log.Printf("⚠️ [ETA] TomTom failed, fallback local: %v", err)
distanceKm = services.CalculateDistance(*livreurLocation, toCoords)
etaMinutes = services.CalculateETA(distanceKm)
}
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := map[string]any{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
}
db.Redis.HSet(db.RedisCtx, etaKey, etaCache)
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
log.Printf("✅ [ETA] SUCCESS - ETA: %d min, arrivée: %s", etaMinutes, arrivalTime.Format("15:04"))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"eta_minutes": etaMinutes,
"distance_km": fmt.Sprintf("%.2f", distanceKm),
"estimated_arrival": arrivalTime.Format("15:04"),
"eta_available": true,
"with_traffic": err == nil,
"livreur_assign": livreurAssign,
"delivery_address": command["adresse"],
})
}
+794
View File
@@ -0,0 +1,794 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
func GeocodeAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
return
}
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
// 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
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
c.JSON(http.StatusOK, gin.H{
"success": true,
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
"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
func FindNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
var targetCoords services.Coordinates
// Si adresse fournie, la géocoder
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
// Sinon utiliser les coordonnées fournies
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
// Valider les coordonnées
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
})
return
}
// Récupérer les livreurs disponibles
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
// Extraire les usernames
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
// Trouver le plus proche (calcul rapide avec Haversine)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
})
return
}
// Recalculer l'ETA du plus proche avec TomTom pour plus de précision
etaWithTraffic, distanceReal, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err == nil {
nearest.EstimatedTime = etaWithTraffic
nearest.Distance = distanceReal
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min avec trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
} else {
log.Printf("✅ Livreur le plus proche: %s (%.2f km, ~%d min sans trafic)",
nearest.Username, nearest.Distance, nearest.EstimatedTime)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"nearest_delivery_person": gin.H{
"username": nearest.Username,
"latitude": nearest.Location.Latitude,
"longitude": nearest.Location.Longitude,
"distance_km": nearest.Distance,
"eta_minutes": nearest.EstimatedTime,
"traffic_aware": err == nil,
},
})
}
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
var req struct {
Address string `json:"address"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
var targetCoords services.Coordinates
if req.Address != "" {
location, err := geoService.GeocodeAddress(req.Address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse",
})
return
}
targetCoords.Latitude = location.Latitude
targetCoords.Longitude = location.Longitude
} else if req.Latitude != 0 && req.Longitude != 0 {
targetCoords.Latitude = req.Latitude
targetCoords.Longitude = req.Longitude
} else {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Fournir soit une adresse, soit des coordonnées GPS",
})
return
}
if err := services.ValidateCoordinates(targetCoords); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées invalides",
})
return
}
availableLivreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil || len(availableLivreurs) == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur disponible",
})
return
}
usernames := make([]string, len(availableLivreurs))
for i, livreur := range availableLivreurs {
usernames[i] = livreur.Username
}
distances, err := geoService.GetAllDeliveryDistances(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul des distances",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"target": gin.H{
"latitude": targetCoords.Latitude,
"longitude": targetCoords.Longitude,
},
"delivery_persons": distances,
"count": len(distances),
})
}
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
status, ok := command["status"].(string)
if !ok || (status != "pending" && status != "priority") {
c.JSON(http.StatusBadRequest, gin.H{
"error": "La commande doit être en statut 'pending' ou 'priority'",
"current_status": status,
})
return
}
// Récupérer l'adresse de livraison de la commande
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cette commande n'a pas d'adresse de livraison valide",
"command_id": commandID,
"message": "L'adresse de livraison doit être définie lors de la création de la commande",
})
return
}
log.Printf("📍 Adresse de livraison: %s", address)
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de géocoder l'adresse de livraison",
"address": address,
})
return
}
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", address, location.Latitude, location.Longitude)
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": location.Latitude,
"lon": location.Longitude,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder coordonnées destination: %v", err)
} else {
log.Printf("📍 Coordonnées destination sauvegardées pour commande %d: (%.6f, %.6f)",
commandID, location.Latitude, location.Longitude)
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
// Compter le nombre de livreurs actifs
activeCount, _ := database.CountActiveDeliverymen()
if activeCount == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif (tous sont offline)",
})
return
}
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver le livreur actif",
})
return
}
// Calculer ETA avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, singleDeliveryman, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
})
return
}
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
})
return
}
database.SetDeliveryPersonStatus(singleDeliveryman, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(singleDeliveryman)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée au seul livreur actif (sans limite)",
"command_id": commandID,
"assigned_to": gin.H{
"username": singleDeliveryman,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": queueInfo["queue_size"],
"single_driver": true,
"traffic_aware": true,
},
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Impossible de trouver un livreur pour la distribution forcée",
})
return
}
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur calcul ETA",
})
return
}
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation forcée",
})
return
}
database.SetDeliveryPersonStatus(leastLoaded, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(leastLoaded)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
"command_id": commandID,
"forced": true,
"assigned_to": gin.H{
"username": leastLoaded,
"travel_time": travelTime,
"distance_km": distance,
"queue_position": currentSize + 1,
"over_capacity": true,
"traffic_aware": true,
},
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
return
}
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount,
"max_per_deliveryman": db.MAX_COMMANDS_PER_DELIVERYMAN,
})
return
}
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur avec position GPS valide",
})
return
}
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil {
// Fallback sur le calcul initial
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ TomTom indisponible, utilisation du calcul Haversine")
}
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de l'assignation",
})
return
}
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
queueInfo, _ := database.GetDeliverymanQueueInfo(nearest.Username)
etaData, _ := database.GetCommandETA(commandID)
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande assignée à la queue du livreur",
"command_id": commandID,
"assigned_to": gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
"queue_position": queueInfo["queue_size"],
"single_driver": activeCount == 1,
"traffic_aware": err == nil,
},
"eta": etaData,
"delivery_address": address,
"coordinates": gin.H{
"latitude": location.Latitude,
"longitude": location.Longitude,
},
"queue_info": queueInfo,
})
}
func AutoAssignAllPendingCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
commands, err := database.GetAllCommands("pending", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des commandes",
})
return
}
if len(commands) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune commande en attente",
"assigned": 0,
})
return
}
log.Printf("📋 %d commandes en attente à assigner", len(commands))
var assigned []gin.H
var failed []gin.H
for _, cmd := range commands {
commandID, ok := cmd["id"].(int)
if !ok {
if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat)
} else {
continue
}
}
address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Adresse de livraison manquante",
})
continue
}
location, err := geoService.GeocodeAddress(address)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": fmt.Sprintf("Impossible de géocoder: %s", address),
})
continue
}
targetCoords := services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur disponible",
})
continue
}
usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username
}
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": "Aucun livreur avec position GPS",
})
continue
}
travelTime := nearest.EstimatedTime
distance := nearest.Distance
// Assigner à la queue
err = database.AssignCommandToDeliverymanQueue(commandID, nearest.Username, travelTime)
if err != nil {
failed = append(failed, gin.H{
"command_id": commandID,
"error": err.Error(),
})
continue
}
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime any
totalETA = "N/A"
waitTime = "N/A"
if etaData != nil {
if val, exists := etaData["total_eta_minutes"]; exists {
totalETA = val
}
if val, exists := etaData["wait_time_minutes"]; exists {
waitTime = val
}
}
assigned = append(assigned, gin.H{
"command_id": commandID,
"assigned_to": nearest.Username,
"distance_km": distance,
"total_eta_minutes": totalETA,
"wait_time_minutes": waitTime,
"travel_time": travelTime,
})
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
}
queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("%d commandes assignées, %d échecs", len(assigned), len(failed)),
"total_pending": len(commands),
"assigned_count": len(assigned),
"failed_count": len(failed),
"assigned": assigned,
"failed": failed,
"queues_overview": queuesOverview,
"note": "ETAs calculés avec Haversine pour rapidité, précision TomTom disponible individuellement",
})
}
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
func GetAllDeliveryQueues(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
overview, err := database.GetAllQueuesOverview()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération des queues",
})
return
}
var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
for _, key := range keys {
username := key[len("delivery:status:"):]
queueInfo, _ := database.GetDeliverymanQueueInfo(username)
// Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]any
if statusData != "" {
json.Unmarshal([]byte(statusData), &status)
}
deliverymenDetails = append(deliverymenDetails, gin.H{
"username": username,
"status": status["status"],
"queue_info": queueInfo,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"overview": overview,
"deliverymen_detail": deliverymenDetails,
})
}
func GetDeliverymanQueue(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
queueInfo, err := database.GetDeliverymanQueueInfo(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération de la queue",
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"queue_info": queueInfo,
})
}
func ValidateAddress(c *gin.Context) {
geoService := c.MustGet("geoService").(*services.GeoService)
var req struct {
Address string `json:"address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse requise"})
return
}
if !geoService.IsValidAddress(req.Address) {
c.JSON(http.StatusOK, gin.H{"valid": false, "message": "Adresse introuvable ou invalide"})
return
}
location, _ := geoService.GeocodeAddress(req.Address)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"message": "Adresse valide",
"latitude": location.Latitude,
"longitude": location.Longitude,
"display_name": location.DisplayName,
})
}
func calculateTravelTimeWithTomTom(geoService *services.GeoService, deliverymanUsername string, targetLat, targetLon float64) (int, float64, error) {
deliverymanLoc, err := geoService.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil {
return 0, 0, fmt.Errorf("position du livreur introuvable: %w", err)
}
targetCoords := services.Coordinates{
Latitude: targetLat,
Longitude: targetLon,
}
travelTime, distance, err := services.GetETAWithTraffic(*deliverymanLoc, targetCoords)
if err != nil {
distance = services.CalculateDistance(*deliverymanLoc, targetCoords)
travelTime = services.CalculateETA(distance)
log.Printf("⚠️ TomTom indisponible pour %s, fallback: %.2f km -> %d min",
deliverymanUsername, distance, travelTime)
} else {
log.Printf("🛣️ TomTom utilisé pour %s: %.2f km -> %d min (trafic réel)",
deliverymanUsername, distance, travelTime)
}
return travelTime, distance, nil
}
+117
View File
@@ -0,0 +1,117 @@
// ============================================
// handlers/gps_handlers.go
// Gestion des liens GPS et visualisation
// ============================================
package handlers
import (
"fmt"
"gestion/db"
"log"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
)
// GET /api/v2/admin/protected/delivery-persons/:username/map-links
func GetDeliveryPersonMapLinks(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é"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Position GPS non disponible pour ce livreur",
"message": "Le livreur n'a pas encore partagé sa position ou est hors ligne",
})
return
}
if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Coordonnées GPS invalides (0,0)",
"message": "Le livreur doit mettre à jour sa position",
})
return
}
mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveryman": gin.H{
"username": username,
},
"location": gin.H{
"latitude": lat,
"longitude": lon,
"valid": true,
},
"map_links": mapLinks,
})
}
func GetLivreurNavLink(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.GetString("username")
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != username {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette commande ne vous est pas assignée"})
return
}
var wazeLink string
destLat, hasLat := command["dest_latitude"].(float64)
destLon, hasLon := command["dest_longitude"].(float64)
if hasLat && hasLon && destLat != 0 && destLon != 0 {
wazeLink = fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", destLat, destLon)
log.Printf("🗺️ [NAV_LINK] Lien coords pour cmd %d: %s", commandID, wazeLink)
} else if adresse, _ := command["adresse"].(string); adresse != "" {
wazeLink = fmt.Sprintf("waze://?q=%s&navigate=yes", url.QueryEscape(adresse))
log.Printf("🗺️ [NAV_LINK] Lien adresse pour cmd %d: %s", commandID, wazeLink)
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Aucune destination disponible pour cette commande"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"waze_app": wazeLink,
})
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"fmt"
"gestion/db"
"log"
"maps"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed
func GetMyCompletedOrdersWithItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
})
return
}
var enrichedCommands []map[string]any
for _, command := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", command["id"]))
if commandID == 0 {
continue
}
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
items = []map[string]any{}
}
// Ajouter les items à la commande
enrichedCommand := make(map[string]any)
maps.Copy(enrichedCommand, command)
enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items)
enrichedCommands = append(enrichedCommands, enrichedCommand)
}
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
client, err := database.GetClientByUsername(usernameStr)
response := gin.H{
"success": true,
"commands": enrichedCommands,
"count": len(enrichedCommands),
}
if err == nil && client != nil {
response["client_stats"] = gin.H{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"total_commands": client.Command,
"points_extra": client.PointsExtra,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
func GetOrderHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID de commande invalide",
})
return
}
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
})
return
}
cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous appartient pas",
})
return
}
logs, err := database.GetCommandLogs(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]any{}
}
items, err := database.GetCommandItems(commandID)
if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
items = []map[string]any{}
}
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"logs": logs,
"logs_count": len(logs),
"items": items,
"items_count": len(items),
})
}
@@ -0,0 +1,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),
})
}
+192
View File
@@ -0,0 +1,192 @@
package handlers
import (
"encoding/json"
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetClientNotifications retourne les notifications du client connecté
// GET /api/v1/notifications
func GetClientNotifications(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
return
}
type Notification struct {
CommandID int `json:"command_id"`
Type string `json:"type"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
Read bool `json:"read"`
}
notifications := make([]Notification, 0, len(results))
unreadCount := 0
for _, raw := range results {
var n Notification
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
notifications = append(notifications, n)
if !n.Read {
unreadCount++
}
}
log.Printf("✅ [GET_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"unread_count": unreadCount,
"total": len(notifications),
})
}
// GetLivreurNotifications retourne les notifications du livreur connecté
// GET /api/v1/livreur/notifications
func GetLivreurNotifications(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
return
}
type Notification struct {
CommandID int `json:"command_id"`
Type string `json:"type"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
Read bool `json:"read"`
}
notifications := make([]Notification, 0, len(results))
unreadCount := 0
for _, raw := range results {
var n Notification
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
notifications = append(notifications, n)
if !n.Read {
unreadCount++
}
}
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"unread_count": unreadCount,
"total": len(notifications),
})
}
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
// POST /api/v1/livreur/notifications/read
func MarkLivreurNotificationsRead(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
if err != nil {
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
return
}
markedCount := 0
for i, raw := range results {
var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
if read, ok := n["read"].(bool); ok && read {
continue
}
n["read"] = true
updated, _ := json.Marshal(n)
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
markedCount++
}
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"marked_count": markedCount,
})
}
// MarkNotificationsRead marque toutes les notifications comme lues
// POST /api/v1/notifications/read
func MarkNotificationsRead(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
// Récupérer toutes les notifications
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
if err != nil {
log.Printf("❌ [MARK_NOTIFICATIONS_READ] Erreur Redis LRange: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
return
}
// Réécrire chaque notification avec read=true
markedCount := 0
for i, raw := range results {
var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
if read, ok := n["read"].(bool); ok && read {
continue
}
n["read"] = true
updated, _ := json.Marshal(n)
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
markedCount++
}
log.Printf("✅ [MARK_NOTIFICATIONS_READ] %d notifications marquées lues pour %s", markedCount, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"marked_count": markedCount,
})
}
+596
View File
@@ -0,0 +1,596 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"gestion/utils"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type BasketsRequest struct {
Username string `json:"username"`
ProductID int `json:"product_id"`
NameProduct string `json:"name_product"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
}
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req BasketsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
username, ok := c.Get("username")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
req.Username = username.(string)
if req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
if req.ProductID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id requis"})
return
}
if p, err := database.GetProductByID(req.ProductID); err == nil && p.ComingSoon {
c.JSON(http.StatusBadRequest, gin.H{"error": "Ce produit n'est pas encore disponible"})
return
}
panier, err := database.AddToBasket(req.Username, req.ProductID, req.Quantity)
if err != nil {
log.Printf("❌ [ADD_PANIER] product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
if err.Error() == "stock insuffisant" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant"})
return
}
if strings.Contains(err.Error(), "prix introuvable") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun prix configuré pour ce produit"})
return
}
utils.ServerErr(c, "Impossible d'ajouter le produit au panier", err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit ajouté au panier avec succès",
"panier": panier,
})
}
func GetAllBaskets(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
log.Printf("📦 [GET_PANIER] Requête pour: %s", username)
if username == "" {
log.Printf("❌ [GET_PANIER] Username manquant")
c.JSON(http.StatusBadRequest, gin.H{"error": "Le paramètre 'username' est requis"})
return
}
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr)
log.Printf(" Username demandé: %s", username)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez accéder qu'à votre panier",
})
return
}
username = authUsernameStr
_, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur inexistant"})
return
}
baskets, err := database.GetAllProductsInBasket(username)
if err != nil {
utils.ServerErr(c, "Erreur lors de la récupération du panier", err)
return
}
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier récupéré avec succès",
"panier": baskets,
"count": len(baskets),
"total_amount": totalAmount,
})
}
func DeleteProductFromBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
ID int `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.BindErr(c, err)
return
}
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
itemUsername, err := database.GetBasketItemOwner(req.ID)
if err != nil {
log.Printf("❌ [DEL_PANIER] Article non trouvé: id=%d", req.ID)
c.JSON(http.StatusNotFound, gin.H{"error": "Article non trouvé"})
return
}
if itemUsername != authUsernameStr {
log.Printf("❌ [DEL_PANIER] ⚠️ TENTATIVE DE SUPPRESSION NON AUTORISÉE!")
log.Printf(" Client JWT: %s", authUsernameStr)
log.Printf(" Propriétaire article: %s", itemUsername)
c.JSON(http.StatusForbidden, gin.H{
"error": "Vous ne pouvez supprimer que vos articles",
})
return
}
err = database.DeleteProductFromBasket(req.ID)
if err != nil {
utils.ServerErr(c, "Erreur lors de la suppression", err)
return
}
log.Printf("✅ [DEL_PANIER] Article %d supprimé", req.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Produit supprimé du panier avec succès",
"item_id": req.ID,
})
}
func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
authUsername, hasAuth := c.Get("username")
if !hasAuth {
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
authUsernameStr := authUsername.(string)
log.Printf("🧹 [CLEAR_PANIER] Vider panier de: %s", authUsernameStr)
baskets, err := database.GetAllProductsInBasket(authUsernameStr)
if err != nil {
log.Printf("❌ [CLEAR_PANIER] Erreur récupération: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors du vidage du panier",
})
return
}
err = database.ClearBasket(authUsernameStr)
if err != nil {
utils.ServerErr(c, "Erreur lors du vidage du panier", err)
return
}
log.Printf("✅ [CLEAR_PANIER] Panier %s vidé: %d articles supprimés", authUsernameStr, len(baskets))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Panier vidé avec succès",
})
}
// POST /api/v1/checkout
func ValidateBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
usernameStr := username.(string)
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 {
DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"`
PaymentMethod string `json:"payment_method"`
PayCurrency string `json:"pay_currency"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
return
}
cmd := &models.Command{DeliveryAddress: req.DeliveryAddress}
if err := database.CheckAddress(cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse non reconnue", "corrected_address": cmd.DeliveryAddress})
return
}
req.DeliveryAddress = cmd.DeliveryAddress
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
return
}
}
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
items, err := database.GetBasketItems(usernameStr)
if err != nil {
utils.ServerErr(c, "Impossible de récupérer le panier", err)
return
}
if len(items) == 0 {
log.Printf("❌ [CHECKOUT] Panier vide")
c.JSON(http.StatusBadRequest, gin.H{"error": "Panier vide"})
return
}
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
var cartTotal float64
for _, item := range items {
if price, ok := item["price"].(float64); ok {
cartTotal += price
}
}
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()
var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
if !zoneResult.OK {
if zoneResult.ZoneName == "inconnue" {
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse invalide : aucun code postal détecté",
})
} else if zoneResult.ZoneName == "hors zone" {
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Livraison non disponible pour ce code postal",
"postal_code": zoneResult.PostalCode,
})
} else {
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"referral_balance": referralBalance,
})
}
return
}
var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance
if effectivePayment < zoneResult.MinAmount {
needed := zoneResult.MinAmount + referralBalance
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": needed,
"cart_total": cartTotal,
"missing": needed - cartTotal,
"referral_balance": referralBalance,
"postal_code": zoneResult.PostalCode,
})
return
}
referralUsed = referralBalance
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
if referralUsed > 0 {
if err := database.DebitReferralBalance(usernameStr, referralUsed); err != nil {
log.Printf("❌ [CHECKOUT] Solde parrainage insuffisant pour %s: %v", usernameStr, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Solde parrainage insuffisant ou déjà utilisé"})
return
}
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
}
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"
if isCrypto {
npRaw, npExists := c.Get("nowpayments")
np, npOk := npRaw.(*services.NowPaymentsClient)
if !npExists || !npOk || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
return
}
if req.PayCurrency == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "pay_currency requis pour le paiement crypto (ex: btc, eth, ltc)"})
return
}
}
command, err := database.CreateCommandWithAddress(usernameStr, req.DeliveryAddress)
if err != nil {
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
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"})
return
}
commandID := command.ID
if referralUsed > 0 {
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
}
}
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
payReq := &services.CreatePaymentRequest{
PriceAmount: cartTotal,
PriceCurrency: "eur",
PayCurrency: req.PayCurrency,
OrderID: fmt.Sprintf("%d", commandID),
IPNCallbackURL: ipnURL,
}
payResp, err := np.CreatePayment(payReq)
if err != nil {
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Erreur création paiement NowPayments: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Impossible d'initier le paiement crypto"})
return
}
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
}
priceAmt, _ := payResp.PriceAmount.Float64()
payAmt, _ := payResp.PayAmount.Float64()
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)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"command_id": commandID,
"payment_method": "crypto",
"payment_status": payResp.Status,
"pay_address": payResp.PayAddress,
"pay_amount": payAmt,
"pay_currency": payResp.PayCurrency,
"price_amount": priceAmt,
"price_currency": payResp.PriceCurrency,
"message": "Commande créée - En attente de paiement crypto",
})
return
}
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
var assigned bool
var assignInfo gin.H
location, err := geoService.GeocodeAddress(req.DeliveryAddress)
if err == nil {
log.Printf("📍 [CHECKOUT] Adresse géocodée: %.6f,%.6f", location.Latitude, location.Longitude)
usernames, errEligible := database.GetEligibleDeliverymenForCommand(commandID)
if errEligible == nil && len(usernames) > 0 {
log.Printf("🚚 [CHECKOUT] %d livreur(s) éligible(s) disponibles", len(usernames))
nearest, err := geoService.FindNearestDeliveryPersonFast(services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
}, usernames)
if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location,
services.Coordinates{
Latitude: location.Latitude,
Longitude: location.Longitude,
},
)
if err != nil {
// Fallback sur ETA simple
travelTime = nearest.EstimatedTime
distance = nearest.Distance
log.Printf("⚠️ [CHECKOUT] Fallback ETA: %d min", travelTime)
}
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID,
nearest.Username,
travelTime,
location.Latitude,
location.Longitude,
req.DeliveryAddress,
)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else {
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
}
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
clientOrderID := database.GetClientOrderID(commandID)
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
assigned = true
assignInfo = gin.H{
"username": nearest.Username,
"distance_km": distance,
"travel_time": travelTime,
}
log.Printf("✅ [CHECKOUT] Commande assignée à %s", nearest.Username)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur trouvé: %v", err)
}
} else {
log.Printf("⚠️ [CHECKOUT] Aucun livreur éligible disponible")
}
} else {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
}
newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{
"success": true,
"command_id": commandID,
"client_order_number": command.ClientOrderID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
"referral_used": referralUsed,
"referral_balance": newBalance,
}
if assigned {
resp["message"] = "Commande créée et livreur assigné automatiquement"
resp["auto_assigned"] = true
resp["assigned_to"] = assignInfo
resp["status"] = "assigned"
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d assignée", commandID)
} else {
resp["message"] = "Commande créée - En attente d'assignation"
resp["auto_assigned"] = false
log.Printf("✅ [CHECKOUT] Réponse 200 - Commande %d en attente", commandID)
}
c.JSON(http.StatusCreated, resp)
}
func getBaseURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
}
+160
View File
@@ -0,0 +1,160 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func SetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Parrain string `json:"parrain" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Champ parrain requis"})
return
}
if req.Parrain == targetUsername {
c.JSON(http.StatusBadRequest, gin.H{"error": "Un client ne peut pas être son propre parrain"})
return
}
parrain, err := database.GetClientByUsername(req.Parrain)
if err != nil || parrain == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
return
}
existing, err := database.GetClientParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur vérification parrain", err)
return
}
if existing != "" {
c.JSON(http.StatusConflict, gin.H{"error": "Ce client a déjà un parrain", "parrain": existing})
return
}
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)
return
}
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
c.JSON(http.StatusOK, gin.H{
"message": "Parrain enregistré",
"client": targetUsername,
"parrain": req.Parrain,
"amount_credited": creditAmount,
})
}
// GetMyParrainInfo — GET /api/v1/parrain (client authentifié)
// Retourne le parrain du client + ses filleuls + son solde parrainage.
func GetMyParrainInfo(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
clientUsername := username.(string)
settings, _ := database.GetSettings()
parrain, err := database.GetClientParrain(clientUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération parrain", err)
return
}
filleuls, err := database.GetClientsByParrain(clientUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération filleuls", err)
return
}
balance, err := database.GetClientReferralBalance(clientUsername)
if err != nil {
balance = 0
}
filleulNames := make([]string, 0, len(filleuls))
for _, f := range filleuls {
filleulNames = append(filleulNames, f.Username)
}
c.JSON(http.StatusOK, gin.H{
"parrain": parrain,
"filleuls": filleulNames,
"filleul_count": len(filleulNames),
"referral_balance": balance,
"referral_enabled": settings.ReferralEnabled,
"referral_amount": settings.ReferralAmount,
})
}
// GetClientParrainAdmin — GET /api/v2/admin/protected/client/:username/parrain (admin)
// Retourne les infos parrainage d'un client spécifique.
func GetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
parrain, err := database.GetClientParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération parrain", err)
return
}
filleuls, err := database.GetClientsByParrain(targetUsername)
if err != nil {
utils.ServerErr(c, "Erreur récupération filleuls", err)
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
filleulNames := make([]string, 0, len(filleuls))
for _, f := range filleuls {
filleulNames = append(filleulNames, f.Username)
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"parrain": parrain,
"filleuls": filleulNames,
"filleul_count": len(filleulNames),
"referral_balance": balance,
})
}
// GetParrainStatsAdmin — GET /api/v2/admin/protected/parrain/stats (admin)
// Retourne la liste de tous les parrains avec leur nombre de filleuls et leur solde.
func GetParrainStatsAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
stats, err := database.GetParrainStats()
if err != nil {
utils.ServerErr(c, "Erreur récupération stats parrainage", err)
return
}
c.JSON(http.StatusOK, gin.H{
"parrains": stats,
"total": len(stats),
})
}
+432
View File
@@ -0,0 +1,432 @@
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"
}
// categoryRewardCandidate représente un produit éligible à la récompense pour
// une config de catégorie donnée : son type ("free_product" |
// "half_price_product") et la quantité configurée pour cette catégorie.
type categoryRewardCandidate struct {
Category string
Type string
ProductID int
Name string
Quantity float64
}
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
// AllProducts, sinon la sélection explicite — avec le type et la quantité
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
// l'unique source de vérité (type + produits + quantité).
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
candidates := make([]categoryRewardCandidate, 0)
if reward == nil {
return candidates, nil
}
catalogCache := make(map[string][]models.Product)
for _, cfg := range reward.CategoryConfigs {
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
products, ok := catalogCache[cfg.Category]
if !ok {
var err error
products, err = database.GetProductsByCategory(cfg.Category)
if err != nil {
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
}
catalogCache[cfg.Category] = products
}
for _, p := range products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
})
}
} else if len(cfg.Products) > 0 {
ids := make([]int, len(cfg.Products))
for i, pq := range cfg.Products {
ids[i] = pq.ProductID
}
names, err := database.GetProductNamesByIDs(ids)
if err != nil {
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
}
for _, pq := range cfg.Products {
candidates = append(candidates, categoryRewardCandidate{
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
})
}
}
}
return candidates, nil
}
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) 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, productID int, quantity float64, rewardType string) (float64, error) {
if rewardType != "half_price_product" {
return 0, nil
}
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
if err != nil {
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", 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 ConfigProductResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
}
type EligibleConfigResponse struct {
Category string `json:"category"`
Type string `json:"type"`
AllProducts bool `json:"all_products"`
Products []ConfigProductResponse `json:"products"`
Quantity float64 `json:"quantity"`
}
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"`
}
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
}
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
}
products := make([]ConfigProductResponse, 0, len(cfg.Products))
for _, pq := range cfg.Products {
name := ""
for _, cand := range candidates {
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
name = cand.Name
break
}
}
products = append(products, ConfigProductResponse{
ProductID: pq.ProductID,
ProductName: name,
Quantity: pq.Quantity,
})
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
Products: products,
Quantity: cfg.Quantity,
})
}
}
eligibleRewardItems := make([]RewardItemResponse, 0)
for _, cand := range candidates {
if !poolCats[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: cand.Type,
})
}
pools = append(pools, PoolInfo{
Key: pool.Key,
Name: pool.Name,
Points: pts,
RewardsEarned: earned,
RewardsClaimed: redeemed,
RewardsAvailable: available,
EligibleConfigs: eligibleConfigs,
EligibleRewardItems: eligibleRewardItems,
})
}
// Aperçu global des produits récompense, 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(candidates))
for _, cand := range candidates {
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
continue
}
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: cand.ProductID,
ProductName: cand.Name,
Quantity: cand.Quantity,
Price: price,
Type: cand.Type,
})
}
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 — 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
}
candidates, err := resolveCategoryRewardCandidates(database, reward)
if err != nil {
utils.ServerErr(c, "Erreur résolution produits récompense", err)
return
}
// 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(candidates))
for _, cand := range candidates {
if !poolCategories[cand.Category] {
continue
}
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
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
}
eligibleItems = append(eligibleItems, models.RewardItem{
ProductID: cand.ProductID,
Quantity: cand.Quantity,
Price: price,
})
}
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
}
}
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
// alignées avec celles du pool), on refuse avant de consommer un point —
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
// jamais ajouté au panier (récompense perdue silencieusement).
if len(itemsToAdd) == 0 {
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
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})
}
File diff suppressed because it is too large Load Diff
+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})
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
func GetMyReferralBalance(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
settings, _ := database.GetSettings()
if !settings.ReferralEnabled {
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
return
}
balance, err := database.GetClientReferralBalance(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Solde de parrainage introuvable"})
return
}
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
}
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
func CreditClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Amount float64 `json:"amount" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
return
}
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
utils.ServerErr(c, "Impossible de créditer le solde", err)
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage crédité",
"balance": balance,
})
}
// ResetClientReferralAdmin — DELETE /api/v2/admin/protected/client/:username/referral/reset (admin)
func ResetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
if err := database.ResetClientReferralBalance(targetUsername); err != nil {
utils.ServerErr(c, "Impossible de réinitialiser le solde", err)
return
}
log.Printf("✅ [REFERRAL] Solde parrainage remis à zéro pour %s", targetUsername)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage réinitialisé",
"balance": 0,
})
}
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
func GetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
balance, err := database.GetClientReferralBalance(targetUsername)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client introuvable"})
return
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"balance": balance,
})
}
+108
View File
@@ -0,0 +1,108 @@
package handlers
import (
"gestion/db"
"gestion/models"
"gestion/services"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// GET /api/v1/app-settings — public, sans auth
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
func GetPublicSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
// En cas d'erreur, retourner les valeurs par défaut
settings = db.DefaultSettings()
}
poolNames := make([]string, len(settings.PointsPools))
poolKeys := make([]string, len(settings.PointsPools))
for i, p := range settings.PointsPools {
poolNames[i] = p.Name
poolKeys[i] = p.Key
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"penalties_enabled": settings.PenaltiesEnabled,
"show_amende_score": settings.ShowAmendeScore,
"points_enabled": settings.PointsEnabled,
"points_separated": len(settings.PointsPools) > 1,
"pool_names": poolNames,
"pool_keys": poolKeys,
"referral_enabled": settings.ReferralEnabled,
"referral_amount": settings.ReferralAmount,
"delivery_schedule": settings.DeliverySchedule,
"crypto_payment_enabled": settings.CryptoPaymentEnabled,
"crypto_only": settings.CryptoOnly,
"nowpayments_currencies": settings.NowPaymentsCurrencies,
"telegram_notifications_enabled": settings.TelegramNotificationsEnabled,
"shop_name": settings.ShopName,
"two_fa_enabled": settings.Telegram2FAEnabled,
"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,
})
}
// GET /api/v2/admin/protected/settings
func GetSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
}
// PUT /api/v2/admin/protected/settings
func UpdateSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req models.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
return
}
if err := database.UpdateSettings(req); err != nil {
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
return
}
// Recharger le service Telegram si le token/username a changé
if services.TelegramBot != nil {
services.TelegramBot.Reload(req.TelegramBotToken, req.TelegramBotUsername)
services.TelegramBot.SetNotificationsEnabled(req.TelegramNotificationsEnabled)
if req.TelegramBotToken != "" {
log.Printf("✅ [SETTINGS] Service Telegram rechargé (username: %s)", req.TelegramBotUsername)
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := services.TelegramBot.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [SETTINGS] Erreur enregistrement webhook Telegram: %v", err)
} else {
log.Printf("✅ [SETTINGS] Webhook Telegram enregistré: %s", strings.NewReplacer("\n", "", "\r", "").Replace(webhookURL))
}
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
}
+473
View File
@@ -0,0 +1,473 @@
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
totalPromoDiscount float64
promoOrdersCount int64
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
totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
promoOrdersCount, err = database.PromoOrdersCount(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,
"total_promo_discount": totalPromoDiscount,
"promo_orders_count": promoOrdersCount,
"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,
},
})
}
+385
View File
@@ -0,0 +1,385 @@
package handlers
import (
"gestion/db"
"gestion/models"
"gestion/services"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
)
func TelegramWebhook(c *gin.Context) {
// Vérification du secret webhook
secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token")
if services.TelegramBot == nil || !services.TelegramBot.ValidateWebhookSecret(secret) {
c.Status(http.StatusUnauthorized)
return
}
var update models.TgUpdate
if err := c.ShouldBindJSON(&update); err != nil {
c.Status(http.StatusBadRequest)
return
}
if update.Message == nil {
c.Status(http.StatusOK)
return
}
text := strings.TrimSpace(update.Message.Text)
chatID := update.Message.Chat.ID
// Commande /start <token> — liaison de compte
if token, ok := strings.CutPrefix(text, "/start "); ok {
token = strings.TrimSpace(token)
handleLinkAccount(c, token, chatID)
return
}
// Commande /start sans token — message d'accueil
if text == "/start" {
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID,
"👋 <b>Bienvenue !</b>\n\nPour lier votre compte, générez un token depuis l'application et envoyez <code>/start &lt;token&gt;</code>.")
}
}
c.Status(http.StatusOK)
}
func handleLinkAccount(c *gin.Context, token string, chatID int64) {
if token == "" {
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Token manquant. Générez un nouveau token depuis l'application.")
}
c.Status(http.StatusOK)
return
}
database := c.MustGet("database").(*db.Database)
username, role, err := db.ValidateAndConsumeLinkToken(token)
if err != nil {
log.Printf("⚠️ [TELEGRAM_LINK] Token invalide: %v", err)
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Token invalide ou expiré. Générez un nouveau token depuis l'application.")
}
c.Status(http.StatusOK)
return
}
// Enregistrer le chat_id selon le rôle
var saveErr error
switch role {
case "client":
saveErr = database.SaveClientTelegramChatID(username, chatID)
default:
saveErr = database.SaveUserTelegramChatID(username, chatID)
}
if saveErr != nil {
log.Printf("❌ [TELEGRAM_LINK] Erreur sauvegarde chat_id pour %s (%s): %v", username, role, saveErr)
if services.TelegramBot != nil {
services.TelegramBot.SendMessage(chatID, "❌ Une erreur est survenue. Réessayez.")
}
c.Status(http.StatusOK)
return
}
log.Printf("✅ [TELEGRAM_LINK] Compte %s (%s) lié au chat_id %d", username, role, chatID)
// 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.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,
"✅ <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)
}
func GenerateClientLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
token, err := db.GenerateLinkToken(username, "client")
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
func GenerateLivreurLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
token, err := db.GenerateLinkToken(username, "livreur")
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour livreur %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
func GenerateAdminLinkToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
if services.TelegramBot == nil || !services.TelegramBot.IsConfigured() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Service Telegram non disponible"})
return
}
// Récupérer le rôle réel depuis la DB
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByUsername(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Utilisateur introuvable"})
return
}
token, err := db.GenerateLinkToken(username, user.Role)
if err != nil {
log.Printf("❌ [TELEGRAM] Erreur génération token pour admin %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
botUsername := services.TelegramBot.BotUsername
c.JSON(http.StatusOK, gin.H{
"token": token,
"link_url": "https://t.me/" + botUsername + "?start=" + token,
"message": "/start " + token,
"expires_in": 600,
})
}
// GET /api/v1/telegram/status
func GetClientTelegramStatus(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
_, linked, err := database.GetClientTelegramChatID(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
return
}
c.JSON(http.StatusOK, gin.H{
"linked": linked,
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
})
}
// GET /api/v1/livreur/telegram/status
func GetLivreurTelegramStatus(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
_, linked, err := database.GetUserTelegramChatID(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur vérification"})
return
}
c.JSON(http.StatusOK, gin.H{
"linked": linked,
"enabled": services.TelegramBot != nil && services.TelegramBot.IsConfigured(),
})
}
// ============================================
// DÉLIAISON TELEGRAM
// ============================================
// DELETE /api/v1/telegram/unlink
func UnlinkClientTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
// Désactiver la 2FA si Telegram est délié
if clientID > 0 {
_ = database.SetClientTwoFAEnabled(clientID, false)
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte client %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DELETE /api/v1/livreur/telegram/unlink
func UnlinkLivreurTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte livreur %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DELETE /api/v2/admin/protected/telegram/unlink
func UnlinkAdminTelegram(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserTelegramChatID(username); err != nil {
log.Printf("❌ [TELEGRAM_UNLINK] Erreur pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur déliaison"})
return
}
log.Printf("✅ [TELEGRAM_UNLINK] Compte admin %s délié", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ============================================
// 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)
}
+52
View File
@@ -0,0 +1,52 @@
// ============================================
// handlers/traffic_handlers.go - COMPLET
// ============================================
package handlers
import (
"encoding/json"
"strconv"
)
// getFloatFromMap récupère un float64 depuis une map avec différents types
func getFloatFromMap(m map[string]any, key string) (float64, bool) {
value, exists := m[key]
if !exists || value == nil {
return 0, false
}
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case int32:
return float64(v), true
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case string:
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0, false
}
return f, true
case []byte:
s := string(v)
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
default:
return 0, false
}
}
+479
View File
@@ -0,0 +1,479 @@
// ============================================
// handlers/profile_handlers.go - VERSION CORRIGÉE
// ============================================
// Gestion des modifications de profils
package handlers
import (
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gestion/db"
"gestion/models"
"gestion/utils"
)
// ============================================
// MODIFICATION PROFIL CLIENT (PAR LE CLIENT)
// ============================================
// UpdateMyProfile permet à un client de modifier son propre profil
// PUT /api/v1/profile/update
func UpdateMyProfile(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
var req models.UpdateClientProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas (clients et users)
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Username réservé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
client.Password = string(hashed)
hasChanges = true
}
// Mise à jour du nom
if req.Nom != "" {
req.Nom = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.Nom {
if len(req.Nom) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit contenir au moins 2 caractères"})
return
}
client.Nom = req.Nom
hasChanges = true
}
// Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.Prenom {
if len(req.Prenom) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le prénom doit contenir au moins 2 caractères"})
return
}
client.Prenom = req.Prenom
hasChanges = true
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_MY_PROFILE] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_MY_PROFILE] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_MY_PROFILE] Profil mis à jour: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Profil mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// GetMyProfile retourne le profil du client connecté
// GET /api/v1/profile
func GetMyProfile(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
client, err := database.GetClientByUsername(username.(string))
if err != nil || client == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "client": sanitizeClient(client)})
}
// UpdateClientByAdmin permet à un admin de modifier n'importe quel profil client
// PUT /api/v2/admin/protected/clients/:id
func UpdateClientByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role, exists := c.Get("role")
if !exists || role != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès réservé aux administrateurs",
})
return
}
// Récupérer l'ID du client à modifier
clientIDStr := c.Param("id")
clientID, err := strconv.Atoi(clientIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID client invalide"})
return
}
var req models.AdminUpdateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
// Récupérer le client actuel
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
// ✅ LOG DEBUG - État initial
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État initial - Command: %d, Amende: %.2f",
client.Command, client.Amende)
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != client.Username {
// Vérifier que le nouveau username n'existe pas
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
client.Username = req.Username
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Username modifié: %s", req.Username)
}
// Mise à jour du mot de passe (opération séparée pour garantir l'écriture)
var newHashedPassword string
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
newHashedPassword = string(hashed)
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Mot de passe modifié")
}
// Mise à jour du nom
if req.Nom != "" {
req.Nom = utils.StripHTML(req.Nom)
}
if req.Nom != "" && req.Nom != client.Nom {
client.Nom = req.Nom
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Nom modifié: %s", req.Nom)
}
// Mise à jour du prénom
if req.Prenom != "" {
req.Prenom = utils.StripHTML(req.Prenom)
}
if req.Prenom != "" && req.Prenom != client.Prenom {
client.Prenom = req.Prenom
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Prénom modifié: %s", req.Prenom)
}
// Mise à jour du téléphone
if req.Telephone != "" && req.Telephone != client.Telephone {
if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{"error": "Numéro de téléphone invalide"})
return
}
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
// Vérifier que le téléphone n'est pas déjà utilisé
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil && existingClient.ID != clientID {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
client.Telephone = normalizedPhone
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Téléphone modifié: %s", normalizedPhone)
}
if req.Command != nil && *req.Command != client.Command {
client.Command = *req.Command
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Commandes modifiées: %d → %d", client.Command, *req.Command)
}
if req.Amende != nil {
if *req.Amende < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "L'amende ne peut pas être négative"})
return
}
if *req.Amende != client.Amende {
client.Amende = *req.Amende
hasChanges = true
log.Printf("✏️ [UPDATE_CLIENT_ADMIN] Amendes modifiées: %.2f → %.2f", client.Amende, *req.Amende)
}
}
if !hasChanges {
log.Printf("️ [UPDATE_CLIENT_ADMIN] Aucune modification détectée pour client ID=%d", clientID)
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"client": sanitizeClient(client),
})
return
}
log.Printf("📊 [UPDATE_CLIENT_ADMIN] État avant save - Command: %d, Amende: %.2f",
client.Command, client.Amende)
// Sauvegarder les modifications de profil (hors mot de passe)
if err := database.UpdateClient(client); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour profil: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
// Mettre à jour le mot de passe séparément si demandé
if newHashedPassword != "" {
if err := database.UpdateClientPasswordAndClearFlag(clientID, newHashedPassword); err != nil {
log.Printf("❌ [UPDATE_CLIENT_ADMIN] Erreur mise à jour mot de passe: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour du mot de passe"})
return
}
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Mot de passe mis à jour pour client ID=%d", clientID)
}
log.Printf("✅ [UPDATE_CLIENT_ADMIN] Client mis à jour par admin: %s (ID=%d)", client.Username, client.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Client mis à jour avec succès",
"client": sanitizeClient(client),
})
}
// UpdateUserByAdmin permet à un admin de modifier n'importe quel profil user
// PUT /api/v2/admin/protected/users/:id
func UpdateUserByAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{
"error": "Accès réservé aux administrateurs",
})
return
}
// Récupérer l'ID de l'utilisateur à modifier
userIDStr := c.Param("id")
userID, err := strconv.Atoi(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID utilisateur invalide"})
return
}
var req models.UpdateUserProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
// Récupérer l'utilisateur actuel
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
// Vérifier si des modifications sont demandées
hasChanges := false
// Mise à jour du username
if req.Username != "" {
req.Username = utils.StripHTML(req.Username)
}
if req.Username != "" && req.Username != user.Username {
// Vérifier que le nouveau username n'existe pas (users et clients)
if existingUser, _ := database.GetUserByUsername(req.Username); existingUser != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Username réservé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
user.Username = req.Username
hasChanges = true
}
// Mise à jour du mot de passe
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le mot de passe doit contenir au moins 8 caractères"})
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
user.Password = string(hashed)
hasChanges = true
}
// Mise à jour du rôle
if req.Role != "" && req.Role != user.Role {
// Valider le rôle
validRoles := map[string]bool{
"admin": true,
"cabine": true,
"livreur": true,
}
if !validRoles[req.Role] {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Rôle invalide. Valeurs acceptées: admin, cabine, livreur",
})
return
}
user.Role = req.Role
hasChanges = true
}
if !hasChanges {
c.JSON(http.StatusOK, gin.H{
"message": "Aucune modification détectée",
"user": sanitizeUser(user),
})
return
}
// Sauvegarder les modifications
if err := database.UpdateUser(user); err != nil {
log.Printf("❌ [UPDATE_USER_ADMIN] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la mise à jour"})
return
}
log.Printf("✅ [UPDATE_USER_ADMIN] User mis à jour par admin: %s (ID=%d, Role=%s)", user.Username, user.ID, user.Role)
c.JSON(http.StatusOK, gin.H{
"message": "Utilisateur mis à jour avec succès",
"user": sanitizeUser(user),
})
}
func sanitizeClient(client *models.Client) gin.H {
return gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"points_extra": client.PointsExtra,
"amende": client.Amende,
}
}
func sanitizeUser(user *models.User) gin.H {
return gin.H{
"id": user.ID,
"username": user.Username,
"role": user.Role,
}
}
@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// ============================================
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================
// StartDelivery permet au livreur de démarrer une livraison (passage en in_transit)
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
usernameStr := username.(string)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Coordonnées GPS requises",
})
return
}
log.Printf("🚗 [START] %s démarre livraison cmd %d", usernameStr, commandID)
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
// Vérifier propriété
livreurAssign, _ := command["livreur_assign"].(string)
if livreurAssign != usernameStr {
c.JSON(http.StatusForbidden, gin.H{
"error": "Cette commande ne vous est pas assignée",
})
return
}
// Vérifier le statut actuel
currentStatus, _ := command["status"].(string)
if currentStatus != "assigned" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de démarrer cette livraison",
"current_status": currentStatus,
"message": "La commande doit être en statut 'assigned'",
})
return
}
// Mettre à jour le statut en "en_route"
if err := database.UpdateCommandStatus(commandID, "en_route"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour statut",
})
return
}
// Mettre à jour la position du livreur
database.UpdateLivreurPosition(usernameStr, req.Latitude, req.Longitude, "busy")
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(usernameStr, "busy", commandID)
// Ajouter un log
database.AddCommandLog(commandID, "en_route",
fmt.Sprintf("Livraison démarrée par %s", usernameStr),
usernameStr)
// Notifier le client
if clientUsername, _ := command["username"].(string); clientUsername != "" {
var msg string
etaMinutes := 0
if etaData, err := database.GetCommandETA(commandID); err == nil {
if v, ok := etaData["total_eta_minutes"]; ok {
if n, err2 := strconv.Atoi(v); err2 == nil && n > 0 {
etaMinutes = n
}
}
}
if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(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 {
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 {
var etaStr string
if etaMinutes >= 60 {
h := etaMinutes / 60
m := etaMinutes % 60
if m > 0 {
etaStr = fmt.Sprintf("%dh%02d", h, m)
} else {
etaStr = fmt.Sprintf("%dh", h)
}
} else {
etaStr = fmt.Sprintf("%d min", etaMinutes)
}
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route (~%s)", database.GetClientOrderID(commandID), etaStr)
} else {
msg = fmt.Sprintf("Un livreur vient de prendre en charge ta commande #%d, il est en route.", database.GetClientOrderID(commandID))
}
database.NotifyClient(clientUsername, commandID, "en_route", msg)
}
log.Printf("✅ [START] Livraison %d démarrée", commandID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison démarrée",
"command_id": commandID,
"status": "en_route",
})
}
+59
View File
@@ -0,0 +1,59 @@
package handlers
import (
"gestion/models"
"regexp"
)
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
// postalSet convertit une slice de codes en set pour lookup O(1).
func postalSet(codes []string) map[string]struct{} {
m := make(map[string]struct{}, len(codes))
for _, c := range codes {
m[c] = struct{}{}
}
return m
}
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
func extractPostalCode(address string) string {
m := postalCodeRe.FindStringSubmatch(address)
if len(m) < 2 {
return ""
}
return m[1]
}
// zoneCheckResult est le résultat de la vérification de zone.
type zoneCheckResult struct {
PostalCode string
ZoneName string
MinAmount float64
OK bool
}
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
// Les zones sont lues depuis la DB (settings.PostalZones).
// Code postal introuvable → OK = false (refus).
// Code postal hors de toutes les zones → OK = false (refus).
func checkDeliveryZone(deliveryAddress string, total float64, zones []models.PostalZone) zoneCheckResult {
code := extractPostalCode(deliveryAddress)
if code == "" {
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
}
for _, zone := range zones {
set := postalSet(zone.Codes)
if _, found := set[code]; found {
return zoneCheckResult{
PostalCode: code,
ZoneName: zone.Name,
MinAmount: zone.MinAmount,
OK: total >= zone.MinAmount,
}
}
}
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"gestion/db"
"gestion/routes"
"gestion/services"
"gestion/workers"
"log"
"net/http"
"os"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
if loc, err := time.LoadLocation("Europe/Paris"); err == nil {
time.Local = loc
} else {
log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err)
}
if err := godotenv.Load(); err != nil {
log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.")
}
database := db.InitDB()
defer database.Close()
log.Printf("✅ Database initialisée: %+v", database)
db.InitRedis()
defer db.Redis.Close()
log.Println("✅ Redis initialisé avec succès")
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
log.Println("✅ Service de géolocalisation initialisé")
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()
if telegramService.IsConfigured() {
log.Println("✅ Service Telegram initialisé")
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := telegramService.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook: %v", err)
}
}
} else {
log.Println("️ Service Telegram désactivé (TELEGRAM_BOT_TOKEN non défini)")
}
database.MigrateAddTelegramColumns()
if dbSettings, err := database.GetSettings(); err == nil {
telegramService.Reload(dbSettings.TelegramBotToken, dbSettings.TelegramBotUsername)
telegramService.SetNotificationsEnabled(dbSettings.TelegramNotificationsEnabled)
if dbSettings.TelegramBotToken != "" {
log.Printf("✅ [TELEGRAM] Config chargée depuis la DB (username: %s)", dbSettings.TelegramBotUsername)
if webhookURL := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhookURL != "" {
if err := telegramService.SetWebhook(webhookURL); err != nil {
log.Printf("⚠️ [TELEGRAM] Erreur enregistrement webhook (DB reload): %v", err)
}
}
}
}
// 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("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands()
if err != nil {
log.Printf("❌ Erreur lors du nettoyage initial: %v", err)
} else {
if removed > 0 {
log.Printf("✅ Nettoyage initial terminé: %d commandes invalides supprimées", removed)
} else {
log.Println("✅ Nettoyage initial: Aucune commande invalide trouvée")
}
}
go database.StartQueueCleanupScheduler()
log.Println("✅ Scheduler de nettoyage démarré (toutes les 5 min)")
if err := database.SyncAllDeliverymanStatuses(); err != nil {
log.Printf("⚠️ Erreur synchronisation statuts: %v", err)
} else {
log.Println("✅ Synchronisation des statuts livreurs terminée")
}
go workers.StartAutoAssignmentCron(database, geoService)
log.Println("✅ Cron job auto-assignation démarré (5 min)")
go workers.StartRedisWorkers(database)
log.Println("✅ Workers Redis démarrés")
workers.StartDynamicPaymentChecker(database, func() *services.NowPaymentsClient {
s, err := database.GetSettings()
if err != nil || !s.CryptoPaymentEnabled || s.NowPaymentsAPIKey == "" {
return nil
}
return services.NewNowPaymentsClient(s.NowPaymentsAPIKey, s.NowPaymentsIPNSecret)
}, 2*time.Minute)
log.Println("✅ Worker paiements crypto démarré (2 min)")
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
store := cookie.NewStore([]byte(os.Getenv("SESSION_SECRET")))
store.Options(sessions.Options{
Path: "/",
Domain: "",
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
r.Use(sessions.Sessions("mysession", store))
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://uber-demo.club"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
r.Use(func(c *gin.Context) {
c.Set("database", database)
c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
c.Set("storage", storage)
c.Next()
})
r.Use(func(c *gin.Context) {
settings, err := database.GetSettings()
if err == nil && settings.CryptoPaymentEnabled && settings.NowPaymentsAPIKey != "" {
np := services.NewNowPaymentsClient(settings.NowPaymentsAPIKey, settings.NowPaymentsIPNSecret)
c.Set("nowpayments", np)
}
c.Next()
})
r.Static("/uploads", "./uploads")
routes.SetupRoutes(r, database, geoService, s3Service)
if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
}
}
@@ -0,0 +1,73 @@
package middleware
import (
"fmt"
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func BlockClientIfPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
c.Next()
return
}
clientID, exists := c.Get("client_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
if id, ok := clientID.(int); ok {
if session, err := database.GetClientSession(id); err == nil && session.PenaltyCache > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": session.PenaltyCache,
"blocked": true,
})
c.Abort()
return
}
}
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
usernameStr, ok := username.(string)
if !ok || usernameStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
c.Abort()
return
}
amende, err := database.GetClientAmende(usernameStr)
if err != nil {
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
c.Next()
return
}
if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{
"error": 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,
"blocked": true,
})
c.Abort()
return
}
c.Next()
}
@@ -0,0 +1,87 @@
package middleware
import (
"fmt"
"gestion/db"
"gestion/models"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func OrderHoursMiddleware(c *gin.Context) {
loc, err := time.LoadLocation("Europe/Paris")
if err != nil {
loc = time.UTC
}
now := time.Now().In(loc)
weekday := now.Weekday()
hour := now.Hour()
min := now.Minute()
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
c.Next()
return
}
sched := settings.DeliverySchedule
var day models.DaySchedule
switch weekday {
case time.Monday:
day = sched.Monday
case time.Tuesday:
day = sched.Tuesday
case time.Wednesday:
day = sched.Wednesday
case time.Thursday:
day = sched.Thursday
case time.Friday:
day = sched.Friday
case time.Saturday:
day = sched.Saturday
case time.Sunday:
day = sched.Sunday
}
if !day.Enabled {
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles aujourd'hui",
"message": "La livraison n'est pas disponible ce jour",
"current_time": now.Format("15:04"),
})
c.Abort()
return
}
parseTime := func(t string) int {
var h, m int
fmt.Sscanf(t, "%d:%d", &h, &m)
return h*60 + m
}
currentMinutes := hour*60 + min
openMinutes := parseTime(day.OpenTime)
closeMinutes := parseTime(day.CloseTime)
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles à cette heure",
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
"current_time": now.Format("15:04"),
"open_at": day.OpenTime,
"close_at": day.CloseTime,
})
c.Abort()
return
}
log.Printf("✅ [CLOCK-MWARE] Commande autorisée à %02d:%02d", hour, min)
c.Next()
}
@@ -0,0 +1,509 @@
// ============================================
// middleware/middleware_CORRIGES.go
// ============================================
// Réorganisation complète des middlewares
// Déplace ClientMiddleware, AdminMiddleware, etc depuis handlers/auth.go
package middleware
import (
"fmt"
"gestion/db"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// ============================================
// TYPES JWT CLAIMS
// ============================================
type ClientClaims struct {
ClientID int `json:"client_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
type AdminClaims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
SessionID string `json:"session_id"`
jwt.RegisteredClaims
}
// ============================================
// VARIABLES GLOBALES
// ============================================
var (
userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients
adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur
)
// ============================================
// VALIDATION TOKENS
// ============================================
// validateClientToken valide un token client
func validateClientToken(tokenString string) (*ClientClaims, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return nil, fmt.Errorf("token vide")
}
log.Printf("🔍 [VALIDATE-CLIENT] Validating client token...")
// Parser JWT EN PREMIER avec userJWTSecret (CLIENT)
token, err := jwt.ParseWithClaims(tokenString, &ClientClaims{}, func(token *jwt.Token) (any, error) {
// Vérifier explicitement l'algorithme
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
}
return userJWTSecret, nil
})
if err != nil {
log.Printf("❌ [VALIDATE-CLIENT] JWT parse error: %v", err)
return nil, fmt.Errorf("jwt parsing failed: %v", err)
}
if !token.Valid {
log.Printf("❌ [VALIDATE-CLIENT] Token not valid")
return nil, fmt.Errorf("token not valid")
}
claims, ok := token.Claims.(*ClientClaims)
if !ok {
log.Printf("❌ [VALIDATE-CLIENT] Claims type error")
return nil, fmt.Errorf("invalid claims type")
}
if claims.Role == "" {
return nil, fmt.Errorf("role manquant dans le token")
}
if claims.Issuer != "api-client" {
return nil, fmt.Errorf("issuer invalide")
}
if claims.ExpiresAt.Unix() < time.Now().Unix() {
return nil, fmt.Errorf("token expiré")
}
log.Printf("✅ [VALIDATE-CLIENT] JWT valid - Username: %s, ClientID: %d", claims.Username, claims.ClientID)
return claims, nil
}
// validateAdminToken valide un token admin
func validateAdminToken(tokenString string) (*AdminClaims, error) {
tokenString = strings.TrimSpace(tokenString)
if tokenString == "" {
return nil, fmt.Errorf("token vide")
}
log.Printf("🔍 [VALIDATE-ADMIN] Validating admin token...")
token, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(token *jwt.Token) (any, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing algorithm: %v", token.Method.Alg())
}
return adminJWTSecret, nil
})
if err != nil {
log.Printf("❌ [VALIDATE-ADMIN] JWT parse error: %v", err)
return nil, fmt.Errorf("jwt parsing failed: %v", err)
}
if !token.Valid {
log.Printf("❌ [VALIDATE-ADMIN] Token not valid")
return nil, fmt.Errorf("token not valid")
}
claims, ok := token.Claims.(*AdminClaims)
if !ok {
log.Printf("❌ [VALIDATE-ADMIN] Claims type error")
return nil, fmt.Errorf("invalid claims type")
}
if claims.Role == "" {
return nil, fmt.Errorf("role manquant dans le token")
}
if claims.Issuer != "api-admin" {
return nil, fmt.Errorf("issuer invalide")
}
if claims.ExpiresAt.Unix() < time.Now().Unix() {
return nil, fmt.Errorf("token expiré")
}
log.Printf("✅ [VALIDATE-ADMIN] JWT valid - Username: %s, Role: %s, UserID: %d Issuer: %s ExpiresAt: %d",
claims.Username, claims.Role, claims.UserID, claims.Issuer, claims.ExpiresAt.Unix())
return claims, nil
}
func ClientMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [CLIENT-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateClientToken(tokenStr)
if err != nil {
log.Printf("❌ [CLIENT-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [CLIENT-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [CLIENT-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
c.Set("client_id", claims.ClientID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [CLIENT-MWARE] Client %s (ID=%d) authentifié", claims.Username, claims.ClientID)
c.Next()
}
func AdminMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [ADMIN-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr)
if err != nil {
log.Printf("❌ [ADMIN-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token admin invalide"})
c.Abort()
return
}
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [ADMIN-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [ADMIN-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier rôle — admin uniquement
if claims.Role != "admin" {
log.Printf("❌ [ADMIN-MWARE] Role invalide: %s (admin requis)", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès admin requis"})
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [ADMIN-MWARE] Admin %s (Role=%s, ID=%d) authentifié",
claims.Username, claims.Role, claims.UserID)
c.Next()
}
func CabineMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [CABINE-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr)
if err != nil {
log.Printf("❌ [CABINE-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
// ✅ Check révocation
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [CABINE-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [CABINE-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier rôle — admin ou cabine uniquement
if claims.Role != "admin" && claims.Role != "cabine" {
log.Printf("❌ [CABINE-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès cabine requis"})
c.Abort()
return
}
// Stocker les infos
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [CABINE-MWARE] Cabine %s (%s) authentifiée", claims.Username, claims.Role)
c.Next()
}
func LivreurMiddleware(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Printf("❌ [LIVREUR-MWARE] Authorization header manquant")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token d'autorisation requis"})
c.Abort()
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
database := c.MustGet("database").(*db.Database)
claims, err := validateAdminToken(tokenStr)
if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Token invalide: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token invalide"})
c.Abort()
return
}
// ✅ Check révocation
valid, err := database.IsTokenValid(tokenStr)
if err != nil {
log.Printf("❌ [LIVREUR-MWARE] Erreur vérification token DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur serveur"})
c.Abort()
return
}
if !valid {
log.Printf("❌ [LIVREUR-MWARE] Token révoqué ou expiré")
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token révoqué ou expiré"})
c.Abort()
return
}
// Vérifier rôle — admin ou livreur uniquement
if claims.Role != "admin" && claims.Role != "livreur" {
log.Printf("❌ [LIVREUR-MWARE] Role non autorisé: %s", claims.Role)
c.JSON(http.StatusForbidden, gin.H{"error": "Droits insuffisants - Accès livreur requis"})
c.Abort()
return
}
// Stocker les infos
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("session_id", claims.SessionID)
log.Printf("✅ [LIVREUR-MWARE] Livreur %s (%s) authentifié", claims.Username, claims.Role)
c.Next()
}
func ClientSessionMiddleware(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Récupérer le username du JWT (DÉJÀ VALIDÉ)
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [SESSION-MWARE] Username manquant du JWT")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - username manquant",
})
c.Abort()
return
}
usernameStr := username.(string)
// Récupérer le client_id du JWT
clientID, ok := c.Get("client_id")
if !ok {
log.Printf("❌ [SESSION-MWARE] client_id manquant du JWT")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - client_id manquant",
})
c.Abort()
return
}
clientIDInt, ok := clientID.(int)
if !ok {
log.Printf("❌ [SESSION-MWARE] client_id malformé: %v", clientID)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT invalide - client_id malformé",
})
c.Abort()
return
}
// Vérifier la session Redis
session, err := database.GetClientSession(clientIDInt)
if err != nil {
log.Printf("❌ [SESSION-MWARE] Pas de session Redis pour client %d: %v", clientIDInt, err)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session expirée - Veuillez vous reconnecter",
"action": "Please login again",
})
c.Abort()
return
}
// Vérifier que les données correspondent
if session.Username != usernameStr {
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%s, session=%s", usernameStr, session.Username)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session invalide - Mismatch détecté",
})
c.Abort()
return
}
if session.ClientID != clientIDInt {
log.Printf("❌ [SESSION-MWARE] MISMATCH! JWT=%d, session=%d", clientIDInt, session.ClientID)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Session invalide - client_id mismatch",
})
c.Abort()
return
}
// Prolonger la session
if err := database.RefreshSessionTimeout(clientIDInt); err != nil {
log.Printf("⚠️ [SESSION-MWARE] Erreur refresh: %v", err)
}
// Charger les infos dans le contexte
c.Set("session_id", session.SessionID)
c.Set("session", session)
c.Set("last_activity", session.LastActivity)
log.Printf("✅ [SESSION-MWARE] Session valide pour %s (client_id=%d)", usernameStr, clientIDInt)
c.Next()
}
func RateLimitMiddleware(c *gin.Context) {
// Récupérer le client_id
clientID, ok := c.Get("client_id")
if !ok {
c.Next()
return
}
clientIDInt := clientID.(int)
rateLimitKey := "ratelimit:" + strconv.Itoa(clientIDInt)
// Incrémenter le compteur
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
if err != nil {
log.Printf("⚠️ [RATELIMIT] Erreur: %v", err)
c.Next()
return
}
if count == 1 {
db.Redis.Expire(db.RedisCtx, rateLimitKey, 60*time.Second) // 1 minute
}
if count > 100 {
log.Printf("❌ [RATELIMIT] Client %d dépassé le limite: %d requêtes/min", clientIDInt, count)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop de requêtes - Réessayez dans une minute",
})
c.Abort()
return
}
c.Header("X-RateLimit-Remaining", strconv.FormatInt(100-count, 10))
log.Printf("📊 [RATELIMIT] Client %d: %d/%d requêtes", clientIDInt, count, 100)
c.Next()
}
// LoginRateLimitMiddleware limite les tentatives de connexion par IP.
// Config: 10 tentatives par 15 minutes.
func LoginRateLimitMiddleware(c *gin.Context) {
ip := c.ClientIP()
rateLimitKey := "ratelimit:login:" + ip
count, err := db.Redis.Incr(db.RedisCtx, rateLimitKey).Result()
if err != nil {
log.Printf("⚠️ [LOGIN-RATELIMIT] Erreur Redis: %v", err)
c.Next()
return
}
if count == 1 {
db.Redis.Expire(db.RedisCtx, rateLimitKey, 15*time.Minute)
}
if count > 10 {
log.Printf("❌ [LOGIN-RATELIMIT] IP %s bloquée: %d tentatives/15min", ip, count)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Trop de tentatives de connexion - Réessayez dans 15 minutes",
})
c.Abort()
return
}
c.Header("X-RateLimit-Remaining", strconv.FormatInt(10-count, 10))
c.Next()
}

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