feat(#112): reprise après refus — dossier complet, email resoumission

- GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM)
- Front: lien mail, modale identify login, wizards préremplis, PATCH complet
- Email accusé resoumission aux parents avec n° de dossier
- Fixes préremplissage AM (dates, places, ValueKey étape 2)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 19:19:55 +02:00
co-authored by Cursor
parent c226c2fcdf
commit b99745e0fe
36 changed files with 2460 additions and 85 deletions
@@ -49,6 +49,10 @@ class ApiConfig {
/// Ticket #127 — mot de passe oublié (back à livrer en parallèle).
static const String forgotPassword = '/auth/forgot-password';
static const String resetPassword = '/auth/reset-password';
/// Ticket #112 — reprise après refus (#111 back).
static const String repriseDossier = '/auth/reprise-dossier';
static const String repriseResoumettre = '/auth/reprise-resoumettre';
static const String repriseIdentify = '/auth/reprise-identify';
// Users endpoints
static const String users = '/users';
+111
View File
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import '../models/am_registration_data.dart';
import '../models/user_registration_data.dart';
import '../models/reprise_dossier.dart';
import '../utils/parent_registration_payload.dart';
import 'api/api_config.dart';
import 'api/tokenService.dart';
@@ -258,6 +259,116 @@ class AuthService {
throw Exception(msg);
}
/// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112.
static Future<RepriseDossier> getRepriseDossier(String token) async {
final cleaned = token.trim();
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.repriseDossier}',
).replace(queryParameters: {'token': cleaned});
late final http.Response response;
try {
response = await http.get(uri, headers: ApiConfig.headers);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 404) {
throw Exception('Lien invalide ou expiré.');
}
if (response.statusCode != 200) {
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Réponse invalide du serveur.');
}
final payload = decoded['data'] is Map
? Map<String, dynamic>.from(decoded['data'] as Map)
: decoded;
return RepriseDossier.fromJson(payload);
}
/// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112.
static Future<String> identifyReprise({
required String numeroDossier,
required String email,
}) async {
final num = numeroDossier.trim();
final mail = normalizeEmailText(email);
if (num.isEmpty || mail.isEmpty) {
throw Exception('Numéro de dossier et e-mail requis.');
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'),
headers: ApiConfig.headers,
body: jsonEncode({
'numero_dossier': num,
'email': mail,
}),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 404) {
throw Exception(
'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.',
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw Exception('Réponse invalide du serveur.');
}
final token = decoded['token']?.toString().trim() ?? '';
if (token.isEmpty) {
throw Exception('Réponse invalide du serveur.');
}
return token;
}
/// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112.
static Future<void> resoumettreReprise(Map<String, dynamic> body) async {
final cleaned = body['token']?.toString().trim() ?? '';
if (cleaned.isEmpty) {
throw Exception('Lien invalide ou expiré.');
}
final payload = Map<String, dynamic>.from(body);
payload['token'] = cleaned;
late final http.Response response;
try {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'),
headers: ApiConfig.headers,
body: jsonEncode(payload),
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 404) {
throw Exception('Lien invalide ou expiré.');
}
if (response.statusCode == 200 || response.statusCode == 201) {
return;
}
final decoded = _tryDecodeJsonMap(response.body);
throw Exception(_extractErrorMessage(decoded, response.statusCode));
}
/// Déconnexion de l'utilisateur
static Future<void> logout() async {
await TokenService.clearAll();
@@ -0,0 +1,58 @@
import 'package:p_tits_pas/models/am_registration_data.dart';
import 'package:p_tits_pas/models/reprise_dossier.dart';
import 'package:p_tits_pas/models/user_registration_data.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/utils/reprise_mapper.dart';
/// Contexte reprise après refus (token e-mail ou identify). Ticket #112.
class RepriseSession {
RepriseSession._();
static String? _token;
static String? _role;
static String? _photoUrl;
static String? _photoUrlForApi;
static bool get isActive =>
_token != null && _token!.trim().isNotEmpty;
static String? get token => _token;
static bool get isParent => _role == 'parent';
static bool get isAm => _role == 'assistante_maternelle';
/// URL absolue pour l'affichage.
static String? get photoUrl => _photoUrl;
/// Chemin relatif API (`/uploads/…`) pour PATCH sans re-upload.
static String? get photoUrlForApi => _photoUrlForApi;
static void start({
required String token,
required RepriseDossier dossier,
}) {
_token = token.trim();
_role = dossier.role;
final raw = dossier.photoUrl?.trim();
_photoUrlForApi = raw != null && raw.isNotEmpty ? raw : null;
_photoUrl = _photoUrlForApi != null
? ApiConfig.absoluteMediaUrl(_photoUrlForApi)
: null;
}
static void clear() {
_token = null;
_role = null;
_photoUrl = null;
_photoUrlForApi = null;
}
static void applyToParent(UserRegistrationData data, RepriseDossier dossier) {
RepriseMapper.applyParentDossier(data, dossier);
}
static void applyToAm(AmRegistrationData data, RepriseDossier dossier) {
RepriseMapper.applyAmDossier(data, dossier);
}
}