بناء من الصفر على باك إند 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>
309 lines
15 KiB
Markdown
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 · حراسة الأدمن. الشكل موثّق أعلاه من الكود (📄) ويُثبَّت عند
|
|
توفّر الوصول.
|