بناء من الصفر على باك إند 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>
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. مصائد مؤكَّدة — تُقرأ قبل كتابة طبقة الشبكة
access_token= 15 دقيقة → تجديد استباقي إجباري.balanceوكل المبالغ نصوص — حوّلها.x-app-roleيحدّد الهوية — يُثبَّت عند البناء./tariff/quoteبـcamelCase وحده، ويفشل صامتاً بقيمnull./auth/send-otp= 3 طلبات / 5 دقائق — عدّاد تنازلي إجباري في الواجهة.- الدور يتغيّر بعد اعتماد السائق → إعادة دخول إجبارية.
expired/no_driversقد لا يصلان → مهلة محليّة فيsearching./maps/geocodeمعطوب على المنشور (قائمة دول فارغة).x-tenant-idهو slug لا UUID.
13. ما لم يُتحقّق بعد — يحتاج وصول للسيرفر
e2e-test.mjs الكامل يحتاج OTP_DEV_MODE=true (أرقام عشوائية)؛ على
المنشور هو مطفأ، والـSSH من الماك مرفوض (Permission denied (publickey)).
غير مُتحقَّق حيّاً: دورة الرحلة الكاملة · WebSocket · التسوية والعمولة ·
السحب بـOTP · حراسة الأدمن. الشكل موثّق أعلاه من الكود (📄) ويُثبَّت عند
توفّر الوصول.