[#101] [Frontend] Inscription parent — API, soumission et validation
Squash merge de develop vers master. Livrables principaux (ticket #101 et mise au point associée) : - Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent - Payload DTO (parents, enfants, photos base64, CGU) et services Auth - Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées - Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/ Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.). Closes #101 Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,47 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:p_tits_pas/config/env.dart';
|
||||
|
||||
class ApiConfig {
|
||||
// static const String baseUrl = 'http://localhost:3000/api/v1/';
|
||||
static const String baseUrl = 'https://app.ptits-pas.fr/api/v1';
|
||||
/// Aligné sur [Env.apiBaseUrl] (`--dart-define=API_BASE_URL=...`) pour que les images `/uploads/...` visent le même hôte que l’API.
|
||||
static String get baseUrl {
|
||||
final root = Env.apiBaseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
return '$root/api/v1';
|
||||
}
|
||||
|
||||
/// Origine (schéma + hôte + port) dérivée de [baseUrl], pour préfixer les chemins `/uploads/...`.
|
||||
static String get apiOrigin {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (uri.hasScheme && uri.host.isNotEmpty) {
|
||||
return '${uri.scheme}://${uri.authority}';
|
||||
}
|
||||
return baseUrl.replaceAll(RegExp(r'/api/v1/?.*'), '');
|
||||
}
|
||||
|
||||
/// URL absolue pour une image renvoyée par l’API (chemin type `/uploads/...`).
|
||||
/// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`.
|
||||
static String absoluteMediaUrl(String? pathOrUrl) {
|
||||
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
final u = pathOrUrl.trim();
|
||||
if (u.startsWith('http://') || u.startsWith('https://')) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u');
|
||||
}
|
||||
return u;
|
||||
}
|
||||
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
final out = u.startsWith('/') ? '$base$u' : '$base/$u';
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"',
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
static const String login = '/auth/login';
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/am_registration_data.dart';
|
||||
import '../models/user_registration_data.dart';
|
||||
import '../utils/parent_registration_payload.dart';
|
||||
import 'api/api_config.dart';
|
||||
import 'api/tokenService.dart';
|
||||
import '../utils/nir_utils.dart';
|
||||
@@ -183,26 +185,137 @@ class AuthService {
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
|
||||
final decoded = _tryDecodeJsonMap(response.body);
|
||||
final message = _extractErrorMessage(decoded, response.statusCode);
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
/// Inscription parent complète (POST /auth/register/parent).
|
||||
/// Succès : 201, pas de session — rediriger vers le login.
|
||||
static Future<void> registerParent(UserRegistrationData data) async {
|
||||
final validationError = ParentRegistrationPayload.validateForApi(data);
|
||||
if (validationError != null) {
|
||||
throw Exception(validationError);
|
||||
}
|
||||
|
||||
final body = ParentRegistrationPayload.toJson(data);
|
||||
final String encodedBody;
|
||||
try {
|
||||
encodedBody = jsonEncode(body);
|
||||
} catch (_) {
|
||||
throw Exception(
|
||||
'Impossible de préparer l’envoi (données trop volumineuses ou invalides). '
|
||||
'Réessayez avec une photo plus légère ou sans caractères inhabituels.',
|
||||
);
|
||||
}
|
||||
|
||||
late final http.Response response;
|
||||
try {
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'),
|
||||
headers: ApiConfig.headers,
|
||||
body: encodedBody,
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
|
||||
'un pare-feu ou un bloqueur, puis réessayez.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = _tryDecodeJsonMap(response.body);
|
||||
var message = _extractErrorMessage(decoded, response.statusCode);
|
||||
message = _humanizeParentRegistrationHttpError(message, response.statusCode);
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _tryDecodeJsonMap(String body) {
|
||||
if (body.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplatit `message` (string, liste, ou objet Nest / AllExceptionsFilter).
|
||||
static String? _flattenApiMessageField(dynamic field) {
|
||||
if (field == null) return null;
|
||||
if (field is String) {
|
||||
final t = field.trim();
|
||||
return t.isEmpty ? null : t;
|
||||
}
|
||||
if (field is List) {
|
||||
final parts = field
|
||||
.map(_flattenApiMessageField)
|
||||
.whereType<String>()
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (parts.isEmpty) return null;
|
||||
return parts.join('. ');
|
||||
}
|
||||
if (field is Map) {
|
||||
if (field['message'] != null) {
|
||||
final inner = _flattenApiMessageField(field['message']);
|
||||
if (inner != null) return inner;
|
||||
}
|
||||
final err = field['error'];
|
||||
if (err is String && err.trim().isNotEmpty) return err.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
|
||||
static String _extractErrorMessage(dynamic decoded, int statusCode) {
|
||||
const fallback = 'Erreur lors de l\'inscription';
|
||||
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
||||
final msg = decoded['message'];
|
||||
if (msg == null) {
|
||||
final err = decoded['error'];
|
||||
return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)';
|
||||
if (decoded == null || decoded is! Map) {
|
||||
return '$fallback ($statusCode)';
|
||||
}
|
||||
if (msg is String) return msg;
|
||||
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
|
||||
if (msg is Map && msg['message'] != null) return msg['message'].toString();
|
||||
final map = decoded;
|
||||
final fromMessage = _flattenApiMessageField(map['message']);
|
||||
if (fromMessage != null) return fromMessage;
|
||||
final err = map['error'];
|
||||
if (err is String && err.trim().isNotEmpty) return err.trim();
|
||||
return '$fallback ($statusCode)';
|
||||
}
|
||||
|
||||
/// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à l’inscription.
|
||||
static String _humanizeParentRegistrationHttpError(String msg, int statusCode) {
|
||||
if (statusCode == 409) return msg;
|
||||
|
||||
final lower = msg.toLowerCase().trim();
|
||||
final looksLikeGenericServerError = lower == 'internal server error' ||
|
||||
lower == 'internal server error.' ||
|
||||
lower.contains('internal server error');
|
||||
|
||||
if (statusCode >= 500 && looksLikeGenericServerError) {
|
||||
return 'Impossible d\'enregistrer votre demande pour le moment. '
|
||||
'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : '
|
||||
'connectez-vous ou utilisez une autre adresse pour le parent principal '
|
||||
'(et pour le co-parent si vous en indiquez un). '
|
||||
'Si le problème continue, réessayez plus tard ou contactez le support.';
|
||||
}
|
||||
|
||||
if (statusCode >= 500) {
|
||||
if (lower.contains('duplicate') ||
|
||||
lower.contains('unique constraint') ||
|
||||
lower.contains('23505') ||
|
||||
lower.contains('already exist')) {
|
||||
return 'Cette adresse e-mail semble déjà enregistrée. '
|
||||
'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.';
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/// Rafraîchit le profil utilisateur depuis l'API
|
||||
static Future<AppUser?> refreshCurrentUser() async {
|
||||
final token = await TokenService.getToken();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
@@ -92,8 +93,12 @@ class UserService {
|
||||
/// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||
static Future<DossierUnifie> getDossier(String numeroDossier) async {
|
||||
final encoded = Uri.encodeComponent(numeroDossier);
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
|
||||
if (kDebugMode) {
|
||||
debugPrint('[PetitsPas/dossier] GET $uri');
|
||||
}
|
||||
final response = await http.get(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||
uri,
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode == 404) {
|
||||
@@ -113,7 +118,21 @@ class UserService {
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw FormatException('Réponse invalide');
|
||||
}
|
||||
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
||||
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[PetitsPas/dossier] réponse OK type=${dossier.type} | '
|
||||
'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}',
|
||||
);
|
||||
if (dossier.isFamily) {
|
||||
final f = dossier.asFamily;
|
||||
debugPrint(
|
||||
'[PetitsPas/dossier] famille ${f.numeroDossier} | '
|
||||
'${f.enfants.length} enfant(s)',
|
||||
);
|
||||
}
|
||||
}
|
||||
return dossier;
|
||||
} catch (e) {
|
||||
if (e is FormatException) rethrow;
|
||||
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||
|
||||
Reference in New Issue
Block a user