chore: أرشفة تطبيقات ولوحات تريبز التي سيحلّ محلها كود سيرو

استكمال قرار 2026-07-27 (كوميت fff76c9 أرشف الباك إند):
  apps/rider          → apps/rider-archive-cubit
  apps/driver         → apps/driver-archive-cubit
  dashboards/admin-web    → dashboards/admin-web-archive
  dashboards/service-web  → dashboards/service-web-archive

إعادة تسمية بلا تعديل محتوى. rider/driver كانا إعادة بناء Cubit مبنية على
عقد NestJS المؤرشف. admin-web و service-web نموذجان بملف HTML واحد (164 و
128 سطراً) — يحلّ محلهما siro_admin و siro_service المكتملان.

يُحمل من المؤرشف ولا يُعاد اكتشافه: عقد map-saas المثبَّت بالتجربة في
apps/rider-archive-cubit/lib/core/api/ (x-api-key ترويسة، reverse مصفوفة،
route في points) ودرس مسار FCM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 05:12:04 +03:00
co-authored by Claude Opus 5
parent fff76c949f
commit 9909d9b4c1
809 changed files with 0 additions and 0 deletions
@@ -0,0 +1,105 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:device_info_plus/device_info_plus.dart';
import '../config.dart';
import '../storage/token_store.dart';
/// عميل HTTP لكل نداءات الـAPI (docs/23 §4).
///
/// **نقطة الشبكة الوحيدة** في التطبيق: لا `http.get` ولا `Dio` آخر في أي
/// مكان. السبب أن كل ما يفرضه السيرفر (المستأجر · التوكن · بصمة الجهاز ·
/// التوقيع) يُحقن هنا مرة واحدة؛ نداءٌ يلتفّ حول هذا الملف هو نداء بلا
/// مصادقة يفشل عند تفعيل الحراسات على السيرفر.
class ApiClient {
final Dio dio;
final TokenStore _store;
/// بصمة الجهاز (docs/17 — D2). تُحسب مرة وتُخزَّن: قراءتها من النظام في كل
/// طلب استدعاءُ قناة أصيلة بلا داعٍ.
static String? _deviceId;
ApiClient(this._store)
: dio = Dio(BaseOptions(
baseUrl: AppConfig.baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 20),
headers: {'Content-Type': 'application/json'},
)) {
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
options.headers['x-tenant-id'] = AppConfig.tenantSlug;
// راكب أم سائق — هويتان منفصلتان لنفس الرقم (قرار المالك 2026-07-20 — ب).
options.headers['x-app-role'] = AppConfig.appRole;
final device = await _resolveDeviceId();
if (device != null) options.headers['x-device-id'] = device;
final token = await _store.access();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (e, handler) async {
// تجديد التوكن عند 401 — **مرة واحدة**. بلا علامة `_retried` يعيد
// الطلبُ الفاشل تجديداً يفشل فيعيد… حلقة لانهائية تستنزف البطارية
// وتغرق السيرفر عند انتهاء الجلسة فعلاً.
final isAuthError = e.response?.statusCode == 401;
final alreadyRetried = e.requestOptions.extra['_retried'] == true;
final isRefreshCall = e.requestOptions.path.contains('/auth/refresh');
if (!isAuthError || alreadyRetried || isRefreshCall) {
return handler.next(e);
}
final refreshed = await _tryRefresh();
if (!refreshed) {
await _store.clear(); // الجلسة انتهت فعلاً — لا نُبقي توكناً ميتاً
return handler.next(e);
}
try {
e.requestOptions.extra['_retried'] = true;
final retry = await dio.fetch(e.requestOptions);
return handler.resolve(retry);
} on DioException catch (err) {
return handler.next(err);
}
},
));
}
Future<bool> _tryRefresh() async {
final rt = await _store.refresh();
if (rt == null || rt.isEmpty) return false;
try {
// Dio نظيف: المرور بالعميل نفسه يُدخل التوكن المنتهي في الترويسة.
final plain = Dio(BaseOptions(baseUrl: AppConfig.baseUrl));
final r = await plain.post('/auth/refresh', data: {'refresh_token': rt});
await _store.save(
r.data['access_token'] as String,
(r.data['refresh_token'] ?? rt) as String,
);
return true;
} catch (_) {
return false;
}
}
static Future<String?> _resolveDeviceId() async {
if (_deviceId != null) return _deviceId;
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
_deviceId = (await info.androidInfo).id;
} else if (Platform.isIOS) {
_deviceId = (await info.iosInfo).identifierForVendor;
}
} catch (_) {
// تعذّر قراءة الجهاز: نُرسل بلا الترويسة بدل إسقاط الطلب. السيرفر
// يرفض عند تفعيل الربط، وهذا أوضح من فشل صامت هنا.
_deviceId = null;
}
return _deviceId;
}
}
@@ -0,0 +1,77 @@
/// استثناءات الشبكة الموحّدة — كل خطأ يتحول إلى صنف هنا.
/// docs/23 §4
class ApiException implements Exception {
ApiException(this.message, {this.statusCode, this.type});
final String message;
final int? statusCode;
final ApiExceptionType? type;
@override
String toString() => 'ApiException($statusCode): $message';
}
enum ApiExceptionType {
network,
timeout,
unauthorized,
forbidden,
notFound,
server,
unknown,
}
/// تحليل DioException إلى ApiException
ApiException fromDioError(Object error) {
final dio = error;
final type = switch (dio.runtimeType.toString()) {
'DioException' => _mapDioType(dio),
_ => ApiExceptionType.unknown,
};
final statusCode = _extractStatusCode(dio);
final message = _extractMessage(dio, statusCode);
return ApiException(message, statusCode: statusCode, type: type);
}
int? _extractStatusCode(Object error) {
try {
return (error as dynamic).response?.statusCode as int?;
} catch (_) {
return null;
}
}
String _extractMessage(Object error, int? statusCode) {
try {
final data = (error as dynamic).response?.data;
if (data is Map) {
return (data['message'] ?? data['error'] ?? 'خطأ غير معروف').toString();
}
} catch (_) {}
return switch (statusCode) {
401 => 'انتهت الجلسة',
403 => 'غير مصرح',
404 => 'غير موجود',
500 => 'خطأ في السيرفر',
_ => 'خطأ غير معروف',
};
}
ApiExceptionType _mapDioType(Object error) {
try {
final dioType = (error as dynamic).type;
return switch (dioType.toString()) {
'DioExceptionType.connectionTimeout' => ApiExceptionType.timeout,
'DioExceptionType.sendTimeout' => ApiExceptionType.timeout,
'DioExceptionType.receiveTimeout' => ApiExceptionType.timeout,
'DioExceptionType.connectionError' => ApiExceptionType.network,
'DioExceptionType.badResponse' => ApiExceptionType.server,
'DioExceptionType.cancel' => ApiExceptionType.unknown,
_ => ApiExceptionType.unknown,
};
} catch (_) {
return ApiExceptionType.unknown;
}
}