[#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:
2026-04-11 18:07:24 +02:00
parent cde676c4f9
commit fdd1e06e77
49 changed files with 3179 additions and 828 deletions
+43 -2
View File
@@ -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 lAPI.
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 lAPI (chemin type `/uploads/...`).
/// On préfixe avec [baseUrl] (…/api/v1), pas seulement lhôte : Traefik nexpose 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';
+122 -9
View File
@@ -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 lenvoi (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 à linscription.
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();
+21 -2
View File
@@ -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"}');