# 38 — عقد الـ API لتطبيقي `rider_new` و `driver_new` المصدر: `backend-archive/` (NestJS) · مُتحقَّق حيّاً على `https://tripz-api.intaleqapp.com/api` بتاريخ **2026-08-04**. هذا الملف **المصدر الوحيد للحقيقة** لطبقة الشبكة في التطبيقين (م0.1 من `docs/37`). > ✅ = تُحقّق شكله حيّاً من السيرفر · 📄 = مقروء من الكود فقط --- ## 0. حالة السيرفر — تحقّق حيّ ``` GET https://tripz-api.intaleqapp.com/api/health → 200 {"status":"ok","service":"tripz-api","time":"2026-08-04T17:52:23Z"} ``` الباك إند المؤرشف **ما زال منشوراً ويعمل** رغم قرار الأرشفة 2026-07-27. المستأجر التجريبي `siro` → `efefa0ad-d803-4a81-9627-125945bb079b`. --- ## 1. الترويسات — في كل طلب | الترويسة | القيمة | إلزامي | |---|---|---| | `x-tenant-id` | **slug** المستأجر (`siro`) لا UUID | نعم — بدونه 401 | | `x-app-role` | `rider` أو `driver` | نعم عملياً (الافتراض `rider`) | | `x-device-id` | بصمة الجهاز | عند `verify-otp` و`refresh` | | `Authorization` | `Bearer ` | لكل ما عدا `auth/*` و`maps/*` و`tenant/config` | **`x-app-role` ليس تجميلاً.** ✅ نفس الرقم `0790000001` أعطى: `rider` → `d3a29299-909b-4267-80f3-887d8e478e04` · `driver` → `f21875fa-15db-4805-be39-ccefb9f63450`. هويتان منفصلتان بمحفظتين منفصلتين. **التطبيق يجب أن يثبّت الترويسة على قيمة واحدة عند البناء ولا يغيّرها أبداً** — تغييرها = مستخدم آخر. --- ## 2. المصادقة — هاتف + OTP، بلا كلمة سر ### `POST /auth/send-otp` 📄 `{ phone }` → `{ success: true, message: 'OTP sent' }` حدّ: **3 طلبات / 5 دقائق**. التطبيق يجب أن يعرض عدّاداً تنازلياً ويمنع الضغط المتكرر، وإلا صار المستخدم محظوراً بعد ثلاث ضغطات. ### `POST /auth/verify-otp` ✅ ```jsonc // الطلب { "phone": "0790000001", "code": "1234", "referral_code": "اختياري" } // الرد 200 { "access_token": "eyJ…", "refresh_token": "eyJ…", "signing_key": "598d8044…c2a2", // ← لتوقيع الطلبات (§8) "user": { "id": "uuid", "tenant_id": "uuid", "phone": "962790000001", // مطبّع دولياً — لا كما أُدخل "phone_bidx": "3f14…", // فهرس أعمى — لا يُعرض "name": null, "role": "rider", "status": "active", "language": "ar", "rating": "5.00", "created_at": "…", "updated_at": "…" } } ``` - **التطبيع**: `0790000001` يُخزَّن `962790000001`. التطبيق يعرض ما أدخله المستخدم، ويرسل ما شاء — الخادم يطبّع. لا تعتمد على تطابق نصّي. - **حدّ المحاولات**: 10 محاولات / 5 دقائق شبكياً + عدّاد لكل (مستأجر، رقم) يُبطل الرمز فوراً عند تجاوزه. رسالة الخطأ: `401 {"message":"Too many attempts — request a new code"}`. - الرمز الخاطئ: `401 {"message":"Invalid or expired OTP code"}`. - **حسابا مراجعة المتاجر** (مقيّدان بمستأجر `siro` فقط): `0790000001` و`0790000002` بالرمز الثابت `1234` — يعملان **بلا** `OTP_DEV_MODE`. ✅ هذان مدخلنا للاختبار حتى نحصل على وصول للسيرفر. ### `POST /auth/refresh` 📄 `{ refresh_token }` + ترويسة `x-device-id` → توكن جديد. ### أعمار التوكن ✅ `access_token` صالح **900 ثانية (15 دقيقة)** — من `iat`/`exp` الفعليين. معناه للتطبيق: interceptor يجدّد استباقياً قبل انتهاء الصلاحية، ولا ينتظر 401 — 15 دقيقة قصيرة جداً في منتصف رحلة. --- ## 3. المستخدم | النقطة | الوصف | |---|---| | `GET /users/me` ✅ | نفس شكل `user` أعلاه بالضبط | | `PATCH /users/me` 📄 | `{ name?, language? }` | --- ## 4. الراكب — دورة الرحلة ### `POST /trips` — طلب رحلة 📄 ```jsonc { "origin": { "lat": 31.9539, "lng": 35.9106 }, "destination": { "lat": 31.98, "lng": 35.87 }, "service_class": "economy", // من /ride-types "city": "…", // اختياري "payment_method": "wallet", "is_round_trip": false, "stops": [{ "lat": …, "lng": …, "label": "…" }], // محطات وسيطة "scheduled_at": "ISO", // حجز مسبق "coupon_code": "…" } → { "trip": { "id", "status": "searching", "quoted_fare": …, … }, "offeredDrivers": 3 } ``` ### باقي النقاط | النقطة | من | ملاحظة | |---|---|---| | `GET /trips/mine` ✅ | راكب | مصفوفة (فارغة `[]` للحساب الجديد) | | `GET /trips/:id` 📄 | الطرفان | | | `POST /trips/:id/cancel` 📄 | الطرفان | الفاعل يُستنتج من الدور | | `GET /trips/available` 📄 | سائق | الطلبات القريبة | | `POST /trips/:id/accept` 📄 | سائق | قبول ذرّي — أول واحد يفوز | | `PATCH /trips/:id/status` 📄 | سائق | `{ status }` | ### آلة الحالات ``` searching → assigned → driver_arriving → driver_arrived → in_progress → completed → paid (+ cancelled · expired/no_drivers) ``` ⚠️ **`expired`/`no_drivers` قد لا يُطلقان أبداً** (ثغرة R1 المسجّلة). لا يجوز أن ينتظر التطبيق حدثاً قد لا يصل — **لازم مهلة محليّة في `searching`** تعرض «لا يوجد سائقون» وتتيح الإلغاء. ### الأجرة عند الإنهاء: `price_for_passenger` و`price_for_driver` **حقلان منفصلان** (الفرق = العمولة). لا تعرض حقلاً واحداً للطرفين. --- ## 5. السائق | النقطة | الجسم | |---|---| | `POST /drivers/apply` | `{ vehicle_make, service_class }` | | `GET /drivers/me` | ملف السائق | | `PATCH /drivers/profile` | `gender · national_number · name_arabic · birthdate · address · license_type · license_categories · license_issue · license_expiry` | | `PATCH /drivers/status` | `{ online: bool }` | | `POST /drivers/location` | `{ lat, lng, heading?, speed? }` → Redis | | `GET /credit` | الرصيد التشغيلي. للراكب: `403 "Not a driver"` ✅ | | `GET /credit/transactions` | كشف الرصيد | **الدور يتغيّر بعد `apply`+`approve`** — التوكن القديم يحمل الدور القديم. لازم إعادة دخول (أو refresh) بعد الاعتماد، وإلا فشلت نقاط السائق بـ403. ### المركبات `GET /vehicles/mine` · `POST /vehicles` (`make · model · year · color · color_hex · plate`) · `PATCH /vehicles/:id/default` · `DELETE /vehicles/:id`. أول مركبة تصير الافتراضية تلقائياً. ### الوثائق `POST /drivers/documents` (رفع) · `GET /drivers/documents/mine` · `GET /drivers/documents/requirements` (يقود شاشة «الوثائق الناقصة»). --- ## 6. المال | النقطة | الرد ✅ | |---|---| | `GET /wallet` | `{ id, tenant_id, user_id, balance: "0.000", currency: "JOD", … }` | | `GET /wallet/transactions` | كشف | | `POST /wallet/topup` | `{ amount }` | ⚠️ **`balance` نصّ لا رقم** (`"0.000"`). لا تمرّره لعملية حسابية بلا تحويل. `currency` من المستأجر (`JOD`/`EGP`/`SYP`) — لا يُثبَّت في التطبيق. **السحب — خطوتان**: `POST /payouts/request` (`{ amount, channel }`) يرسل رمزاً **بلا خصم** → `POST /payouts/:id/confirm` (`{ code }`) يخصم ويحجز. `GET /payouts/mine` للسجل. شاشة السحب يجب أن تعكس الخطوتين لا خطوة واحدة. `POST /payments/charge` · `GET /payments/mine` لبوابات الدفع. --- ## 7. التقييم · التعرفة · الأنواع · الخريطة ### `GET /trips/rating/pending` ✅ `{ "pending": null, "count": 0 }` أو `{ "pending": { "tripId", "role": "rider"|"driver" } }`. **يُنادى عند كل فتح للتطبيق** — إن وُجدت رحلة معلّقة تُفرض شاشة التقييم. `POST /trips/:id/rate` `{ stars, comment? }`. التقييم المزدوج → `400`. ### `GET /ride-types` ✅ ```jsonc [{ "id","code":"economy","name_ar":"اقتصادي","name_en":"Economy", "vehicle_kind":"car","women_only":false,"round_trip_supported":true, "icon":null,"sort":1,"active":true }, …] ``` شاشة اختيار النوع تُبنى من هنا — لا قائمة مكتوبة في التطبيق. انتبه لـ`women_only` و`round_trip_supported` و`sort`. ### `POST /tariff/quote` ✅ ```jsonc { "city": "…", "serviceClass": "economy", "distanceKm": 5.2, "durationMin": 14 } → { "quote": { "window":"normal_evening","flag","distance","time","waiting", "weight","bookingFee","subtotal","surgeMultiplier","total", "currency":"JOD" }, "tariffId", "version" } ``` ⚠️ **الأسماء camelCase هنا، بخلاف بقية الـAPI (snake_case).** وإرسال `origin/destination` بدل `distanceKm/durationMin` يرجّع 200 بقيم `null` صامتة — لا خطأ. يعني: احسب المسافة من `/maps/route` أولاً ثم اطلب التسعيرة، وتحقّق أن `total != null` قبل عرضها. ⚠️ `city` غير معروف → `404 "No active tariff for amman/economy"` ✅ — قيمة `city` تأتي من إعداد المستأجر لا من نصّ حرّ. ### الخريطة | النقطة | الحالة | |---|---| | `GET /maps/route?fromLat&fromLng&toLat&toLng&country` | ✅ `{"distanceKm":7.209,"durationMin":11.5,"provider":"antlaq"}` | | `GET /maps/reverse?lat&lng&country` | ✅ مصفوفة أماكن (`name_ar`, `category`, `distance`, …) | | `GET /maps/geocode?q&country` | ❌ `400 "country must be one of the following values: "` — **قائمة الدول فارغة على السيرفر المنشور** | | `POST /maps/voice-search` · `POST /maps/places` | 📄 | **تعارض قرار يُحسم قبل م4**: قرار `maps-direct-decision` (2026-07-20) يقول كل الخرائط مباشرة لـ map-saas بمفتاح `x-api-key`؛ و`docs/01`/الذاكرة الأقدم تقول geocode/route عبر خادمنا. النقاط أعلاه موجودة وتعمل (عدا geocode). القرار الأحدث يسود — لكن يُثبَّت صراحةً قبل بناء شاشة الخريطة. هذه النقاط **بلا `JwtAuthGuard`** — مفتوحة بلا توكن. --- ## 8. الأمان — علمان مُطفآن ينتظران التطبيق `signing_key` يعود في رد الدخول، ويوجد `PAYMENTS_REQUIRE_SIGNATURE` و `AUTH_REQUIRE_DEVICE_BINDING`. القرار: **يُفعَّلان بعد أن يوقّع التطبيق طلباته ويرسل `x-device-id`**. لذلك يُبنى التوقيع وبصمة الجهاز في طبقة الشبكة **من م3**، لا يُؤجَّل — تأجيله يعني إعادة كتابة الـinterceptors لاحقاً. --- ## 9. الواقع اللحظي (WebSocket) Socket.IO على أصل الـAPI بلا `/api`، المصادقة `auth: { token }`، `transports: ['websocket']`. **يُرسِله الخادم**: `trip:update` · `driver:location` · `trip:offer` · `trip:offer_taken` · `bus:arrived` **يستقبله الخادم**: `trip:join` (`{ tripId }`) · `driver:location` · `call:offer` / `call:answer` / `call:ice` / `call:end` (WebRTC) `trip:offer` و`trip:offer_taken` = قلب شاشة السائق: عرض يصل، ثم يختفي إذا سبقه غيره. لازم انضمام (`trip:join`) **قبل** أي انتقال حالة وإلا فاتت الأحداث. --- ## 10. إعداد المستأجر — يقود واجهة التطبيق ### `GET /tenant/config/:slug` ✅ (بلا توكن) ```jsonc { "slug":"siro","name":"Siro Amman","countryPack":"jo","plan":"brand", "branding":{}, "features":{ "dispatch":false,"wallet":true,"payments":true,"chat":true, "calls":true,"ride_types":true,"market_intel":false,"bots":false, "ads":false,"transit":false,"api_access":false,"driver_tiers":true, "marketing_engine":false,"dynamic_pricing":false,"geofence":false, "negotiator":false,"driver_assurance":false,"coupons":false }, "limits":{ "drivers_max":500,"cities_max":3 } } ``` **كل ميزة في التطبيق تُخفى أو تُظهر من `features`**، لا من علم مكتوب في الكود. هذا هو ربط lite/pro/max بالخادم. يُجلب مرة عند الإقلاع ويُخزَّن. `GET /tenant/logo/:slug` للشعار. --- ## 11. نقاط أخرى تخصّ التطبيقين `POST /notifications/token` `{ token, platform }` — FCM · `POST /trips/:id/messages` + `GET /trips/:id/messages` — الدردشة · `GET /geofence/nearby` · `GET /rewards/referrals` · `GET /rewards/coupons` · `GET /rewards/coupons/preview` · `POST /trips/:id/audio` + `GET .../audio`. **نقاط الأدمن ليست للتطبيقين** (`/admin/*`, `/superadmin/*`, `/tenant/billing`, `/dispatch/*`): محروسة بـ`RolesGuard` وترجع `403` — لا تُستدعى. --- ## 12. مصائد مؤكَّدة — تُقرأ قبل كتابة طبقة الشبكة 1. `access_token` = 15 دقيقة → تجديد استباقي إجباري. 2. `balance` وكل المبالغ **نصوص** — حوّلها. 3. `x-app-role` يحدّد الهوية — يُثبَّت عند البناء. 4. `/tariff/quote` بـcamelCase وحده، ويفشل صامتاً بقيم `null`. 5. `/auth/send-otp` = 3 طلبات / 5 دقائق — عدّاد تنازلي إجباري في الواجهة. 6. الدور يتغيّر بعد اعتماد السائق → إعادة دخول إجبارية. 7. `expired`/`no_drivers` قد لا يصلان → مهلة محليّة في `searching`. 8. `/maps/geocode` معطوب على المنشور (قائمة دول فارغة). 9. `x-tenant-id` هو **slug** لا UUID. --- ## 13. ما لم يُتحقّق بعد — يحتاج وصول للسيرفر `e2e-test.mjs` الكامل يحتاج `OTP_DEV_MODE=true` (أرقام عشوائية)؛ على المنشور هو **مطفأ**، والـSSH من الماك مرفوض (`Permission denied (publickey)`). غير مُتحقَّق حيّاً: دورة الرحلة الكاملة · WebSocket · التسوية والعمولة · السحب بـOTP · حراسة الأدمن. الشكل موثّق أعلاه من الكود (📄) ويُثبَّت عند توفّر الوصول.