Files
tripz-llc/docs/38-api-contract.md
T
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

309 lines
15 KiB
Markdown

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