Files
tripz-llc/docs/38-api-contract.md
Hamza-AyedandClaude Opus 5 69da4abc01 feat(apps): سقالة rider_new و driver_new على Cubit — المرحلة 1
بناء من الصفر على باك إند NestJS، بقرار المالك 2026-08-04 الذي يعكس قرارَي
نقل GetX (2026-07-21) واعتماد باك إند PHP (2026-07-27). لا يُورَّث سطر دارت
من apps/*-archive-cubit، وسيرو مرجع بصري وسلوكي لا مصدر نسخ.

الوثائق:
- docs/37: الخطة الكاملة بستّ مراحل وبواباتها
- docs/38: عقد الـAPI من 37 controller، معظمه متحقَّق حيّاً من السيرفر المنشور
- docs/39: جرد 202 شاشة في تطبيقَي سيرو، مصنّفة داخل/خارج النطاق

الطبقة الأصلية منقولة من *-archive-cubit وحدها لأنها هوية النشر:
- rider_new  → com.mobileapp.store.ride · shorebird 496cb3ac
- driver_new → com.sefer_driver (أندرويد) · com.sefer.driver (iOS) · 68cc9345
- أُصلح تعارض: هدف RunnerTests في driver_new كان يحمل bundle الراكب

الأصول مصدرها *-archive-cubit لا سيرو: أصول الأرشيف مجموعة أشمل (كل صور
سيرو + صور تريبز) وخطوطها هي خطوط docs/26. استُكمل السائق بعشرة ملفات
ناقصة من سيرو (شعارات مزوّدي الدفع + صوتان).

lib/ مكتوب من الصفر (11 ملف لكل تطبيق):
- AppConfig بأعلام const — أساس نموذج lite/pro/max
- TokenStore على التخزين الآمن، يقرأ exp محليّاً بلا حزمة خارجية
- AuthInterceptor بتجديد استباقي وطلقة واحدة — عمر التوكن 15 دقيقة فقط
- ApiClient و ApiException يفهم مصفوفة message في NestJS

flutter analyze نظيف في التطبيقين. البناء الفعلي لم يُجرَّب بعد: بوابة
المرحلة 1 تتطلّب البناء على السيرفر، والوصول إليه غير متاح حالياً.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:21:26 +03:00

15 KiB

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 <access_token> لكل ما عدا 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 ✅

// الطلب
{ "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 — طلب رحلة 📄

{
  "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 ✅

[{ "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 ✅

{ "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 ✅ (بلا توكن)

{ "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 · حراسة الأدمن. الشكل موثّق أعلاه من الكود (📄) ويُثبَّت عند توفّر الوصول.