diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart index 80e7a86b..a9a1ce88 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -5,7 +5,9 @@ import 'feature_gate.dart'; /// A service that handles HTTP communication using the [Dio] client. /// -/// Integrates [FeatureGate] to validate endpoint scopes before each request. +/// Integrates [FeatureGate] for scope validation and throws typed domain +/// exceptions ([ApiException], [NetworkException], [ServerException]) on +/// error responses so repositories never receive silent failures. class ApiService implements BaseApiService { /// Creates an [ApiService] with the given [Dio] instance. ApiService(this._dio); @@ -27,7 +29,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -47,7 +49,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -67,7 +69,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -87,7 +89,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -107,7 +109,7 @@ class ApiService implements BaseApiService { ); return _handleResponse(response); } on DioException catch (e) { - return _handleError(e); + throw _mapDioException(e); } } @@ -117,43 +119,63 @@ class ApiService implements BaseApiService { /// Extracts [ApiResponse] from a successful [Response]. ApiResponse _handleResponse(Response response) { + final dynamic body = response.data; + final String message = body is Map + ? body['message']?.toString() ?? 'Success' + : 'Success'; + return ApiResponse( code: response.statusCode?.toString() ?? '200', - message: response.data['message']?.toString() ?? 'Success', - data: response.data, + message: message, + data: body, ); } - /// Extracts [ApiResponse] from a [DioException]. + /// Maps a [DioException] to a typed domain exception. /// - /// Supports both legacy error format and V2 API error envelope - /// (`{ code, message, details, requestId }`). - ApiResponse _handleError(DioException e) { + /// The V2 API error envelope is `{ code, message, details, requestId }`. + /// This method parses it and throws the appropriate [AppException] subclass + /// so that `BlocErrorHandler` can translate it for the user. + AppException _mapDioException(DioException e) { + // Network-level failures (no response from server). + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.connectionError) { + return NetworkException(technicalMessage: e.message); + } + + final int? statusCode = e.response?.statusCode; + + // Parse V2 error envelope if available. if (e.response?.data is Map) { final Map body = e.response!.data as Map; - return ApiResponse( - code: - body['code']?.toString() ?? - e.response?.statusCode?.toString() ?? - 'error', - message: body['message']?.toString() ?? e.message ?? 'Error occurred', - data: body['data'] ?? body['details'], - errors: _parseErrors(body['errors']), + + final String apiCode = + body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN'; + final String apiMessage = + body['message']?.toString() ?? e.message ?? 'An error occurred'; + + // Map well-known codes to specific exceptions. + if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) { + return NotAuthenticatedException(technicalMessage: apiMessage); + } + + return ApiException( + apiCode: apiCode, + apiMessage: apiMessage, + statusCode: statusCode, + details: body['details'], + technicalMessage: '$apiCode: $apiMessage', ); } - return ApiResponse( - code: e.response?.statusCode?.toString() ?? 'error', - message: e.message ?? 'Unknown error', - errors: {'exception': e.type.toString()}, - ); - } - /// Helper to parse the errors map from various possible formats. - Map _parseErrors(dynamic errors) { - if (errors is Map) { - return Map.from(errors); + // Server error without a parseable body. + if (statusCode != null && statusCode >= 500) { + return ServerException(technicalMessage: e.message); } - return const {}; + + return UnknownException(technicalMessage: e.message); } } diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart index 3e6a5435..de1e228e 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart @@ -8,7 +8,7 @@ class ApiResponse { this.errors = const {}, }); - /// The response code (e.g., '200', '404', or custom error code). + /// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR'). final String code; /// A descriptive message about the response. @@ -19,4 +19,13 @@ class ApiResponse { /// A map of field-specific error messages, if any. final Map errors; + + /// Whether the response indicates success (HTTP 2xx). + bool get isSuccess { + final int? statusCode = int.tryParse(code); + return statusCode != null && statusCode >= 200 && statusCode < 300; + } + + /// Whether the response indicates failure. + bool get isFailure => !isSuccess; } diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart index 1acda2e3..495e30e3 100644 --- a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart @@ -1,3 +1,4 @@ +import '../../../exceptions/app_exception.dart'; import 'api_response.dart'; import 'base_api_service.dart'; @@ -14,10 +15,13 @@ abstract class BaseCoreService { /// Standardized wrapper to execute API actions. /// - /// This handles generic error normalization for unexpected non-HTTP errors. + /// Rethrows [AppException] subclasses (domain errors) directly. + /// Wraps unexpected non-HTTP errors into an error [ApiResponse]. Future action(Future Function() execution) async { try { return await execution(); + } on AppException { + rethrow; } catch (e) { return ApiResponse( code: 'CORE_INTERNAL_ERROR', diff --git a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart index f8abd5c0..659aad24 100644 --- a/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart +++ b/apps/mobile/packages/domain/lib/src/exceptions/app_exception.dart @@ -286,6 +286,54 @@ class NoActiveShiftException extends ShiftException { // NETWORK/GENERIC EXCEPTIONS // ============================================================ +// ============================================================ +// API EXCEPTIONS (mapped from V2 error envelope codes) +// ============================================================ + +/// Thrown when the V2 API returns a non-success response. +/// +/// Carries the full error envelope so callers can inspect details. +class ApiException extends AppException { + /// Creates an [ApiException]. + const ApiException({ + required String apiCode, + required String apiMessage, + this.statusCode, + this.details, + super.technicalMessage, + }) : super(code: apiCode); + + /// The HTTP status code (e.g. 400, 404, 500). + final int? statusCode; + + /// The V2 API error code string (e.g. 'VALIDATION_ERROR'). + String get apiCode => code; + + /// Optional details from the error envelope. + final dynamic details; + + @override + String get messageKey { + switch (code) { + case 'VALIDATION_ERROR': + return 'errors.generic.validation_error'; + case 'NOT_FOUND': + return 'errors.generic.not_found'; + case 'FORBIDDEN': + return 'errors.generic.forbidden'; + case 'UNAUTHENTICATED': + return 'errors.auth.not_authenticated'; + case 'CONFLICT': + return 'errors.generic.conflict'; + default: + if (statusCode != null && statusCode! >= 500) { + return 'errors.generic.server_error'; + } + return 'errors.generic.unknown'; + } + } +} + /// Thrown when there is no network connection. class NetworkException extends AppException { const NetworkException({super.technicalMessage}) diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 08185e59..c8fc50a3 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -55,20 +55,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { final Map body = response.data as Map; - // Check for V2 error responses. - if (response.code != '200' && response.code != '201') { - final String errorCode = body['code']?.toString() ?? response.code; - if (errorCode == 'INVALID_CREDENTIALS' || - response.message.contains('INVALID_LOGIN_CREDENTIALS')) { - throw InvalidCredentialsException( - technicalMessage: response.message, - ); - } - throw SignInFailedException( - technicalMessage: '$errorCode: ${response.message}', - ); - } - // Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens // to subsequent requests. The V2 API already validated credentials, so // email/password sign-in establishes the local Firebase Auth state. @@ -115,12 +101,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { }, ); - // Check for V2 error responses. final Map body = response.data as Map; - if (response.code != '201' && response.code != '200') { - final String errorCode = body['code']?.toString() ?? response.code; - _throwSignUpError(errorCode, response.message); - } // Step 2: Sign in locally to Firebase Auth so AuthInterceptor works // for subsequent requests. The V2 API already created the Firebase