From 90acf50b55499910a7c5f9b8a193243759bf9fbf Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 18 Mar 2026 13:42:47 +0100 Subject: [PATCH] Add idempotency handling for order booking creation using Redis to prevent duplicate requests. --- .../nginx/app/routes/orderBookingRoute.php | 194 +++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index f7eae14c..f275e784 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\redis; use Exception; use modules\subusers\helpers\subusers_permission_node_key; use objects\departments_o; @@ -65,11 +66,29 @@ class orderBookingRoute 'pickup' => $pickup, 'items' => $items, ]; + $fingerprint = $this->buildBookingCreationFingerprint($data); + $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); + if ($cachedBooking !== null) { + $response->success($cachedBooking->asArray()); + } + if (!$this->reserveBookingCreationSlot($fingerprint)) { + $cachedBooking = $this->getBookingFromIdempotencyCache($fingerprint); + if ($cachedBooking !== null) { + $response->success($cachedBooking->asArray()); + } + $response->error('A similar booking request is already being processed. Please wait a moment and retry.', 429); + } /** * Create the object */ $order_bookings_o = new order_bookings_o(); - $order_bookings_o->add($data); + try { + $order_bookings_o->add($data); + $this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id); + } catch (\Throwable $e) { + $this->clearBookingCreationSlot($fingerprint); + throw $e; + } $response->success($order_bookings_o->asArray()); }, [ @@ -534,4 +553,175 @@ class orderBookingRoute } } -} \ No newline at end of file + private function buildBookingCreationFingerprint(array $data): string + { + $payload = [ + 'customer_number' => (int)($data['customer_number'] ?? 0), + 'department' => (int)($data['department'] ?? 0), + 'reg_1' => (string)($data['reg_1'] ?? ''), + 'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null), + 'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null), + 'datetime' => (string)($data['datetime'] ?? ''), + 'note' => $this->normalizeNullableString($data['note'] ?? null), + 'reference' => $this->normalizeNullableString($data['reference'] ?? null), + 'po' => $this->normalizeNullableString($data['po'] ?? null), + 'pickup' => (bool)($data['pickup'] ?? false), + 'items' => $this->normalizeItemsForFingerprint((array)($data['items'] ?? [])), + ]; + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + return md5((string)microtime(true)); + } + + return hash('sha256', $encoded); + } + + private function normalizeNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + if (strtolower($normalized) === 'null') { + return null; + } + + return $normalized; + } + + private function normalizeItemsForFingerprint(array $items): array + { + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $id = (int)($item['id'] ?? 0); + $quantity = (int)($item['quantity'] ?? 0); + if ($id < 1 || $quantity < 1) { + continue; + } + $normalized[] = [ + 'id' => $id, + 'quantity' => $quantity, + ]; + } + + usort($normalized, static function (array $a, array $b): int { + if ($a['id'] === $b['id']) { + return $a['quantity'] <=> $b['quantity']; + } + return $a['id'] <=> $b['id']; + }); + + return $normalized; + } + + private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return null; + } + $id = (int)($redis->get($this->bookingIdempotencyResultKey($fingerprint)) ?? 0); + if ($id < 1) { + return null; + } + $booking = (new order_bookings_o())->select($id); + if (!$booking->exists()) { + return null; + } + return $booking; + } catch (\Throwable) { + return null; + } + } + + private function reserveBookingCreationSlot(string $fingerprint): bool + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return true; + } + return $redis->set_if_absent_with_expiration( + $this->bookingIdempotencyLockKey($fingerprint), + '1', + $this->bookingIdempotencyLockTtlSeconds() + ); + } catch (\Throwable) { + // Fail open when Redis is unavailable to avoid blocking all bookings. + return true; + } + } + + private function storeBookingIdempotencyResult(string $fingerprint, int $bookingId): void + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return; + } + $redis->setEx( + $this->bookingIdempotencyResultKey($fingerprint), + (string)$bookingId, + $this->bookingIdempotencyResultTtlSeconds() + ); + $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); + } catch (\Throwable) { + // Best effort only. + } + } + + private function clearBookingCreationSlot(string $fingerprint): void + { + try { + $redis = $this->resolveRedisClient(); + if ($redis === null) { + return; + } + $redis->delete($this->bookingIdempotencyLockKey($fingerprint)); + } catch (\Throwable) { + // Best effort only. + } + } + + private function resolveRedisClient(): ?redis + { + try { + if (defined('redis')) { + $instance = constant('redis'); + if ($instance instanceof redis) { + return $instance; + } + } + return (new redis())->connect(); + } catch (\Throwable) { + return null; + } + } + + private function bookingIdempotencyLockKey(string $fingerprint): string + { + return 'order_booking:idempotency:lock:' . $fingerprint; + } + + private function bookingIdempotencyResultKey(string $fingerprint): string + { + return 'order_booking:idempotency:result:' . $fingerprint; + } + + private function bookingIdempotencyLockTtlSeconds(): int + { + return 30; + } + + private function bookingIdempotencyResultTtlSeconds(): int + { + return 300; + } + +}