petitspas/frontend/lib/services/user_service.dart
Julien Martin 1dddc67933 feat(#140): dashboard admin ch.6 — fiche parent, enfants, affiliation
Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi.
Front: modale parent éditable, onglet Enfants, fiche enfant, UserService.

Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138.
Hors scope: fiche AM (#131), création admin (#129).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 00:35:00 +02:00

735 lines
25 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class DocumentActifInfo {
final String id;
final String type;
final int version;
final String url;
const DocumentActifInfo({
required this.id,
required this.type,
required this.version,
required this.url,
});
factory DocumentActifInfo.fromJson(Map<String, dynamic> json) {
return DocumentActifInfo(
id: (json['id'] ?? '').toString(),
type: (json['type'] ?? '').toString(),
version: int.tryParse((json['version'] ?? '').toString()) ?? 0,
url: (json['url'] ?? '').toString(),
);
}
}
class DocumentsActifsInfo {
final DocumentActifInfo cgu;
final DocumentActifInfo privacy;
const DocumentsActifsInfo({
required this.cgu,
required this.privacy,
});
}
class UserService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String? _toStr(dynamic v) {
if (v == null) return null;
if (v is String) return v;
return v.toString();
}
static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue';
if (err is String) return err;
if (err is Map) {
final m = err['message'];
if (m is String) return m;
if (m is Map && m['message'] is String) return m['message'] as String;
if (m != null) return _toStr(m) ?? 'Erreur inconnue';
}
return _toStr(err) ?? 'Erreur inconnue';
}
/// Documents légaux actifs (CGU + privacy). GET /documents-legaux/actifs.
static Future<DocumentsActifsInfo> getDocumentsLegauxActifs() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}/documents-legaux/actifs'),
headers: ApiConfig.headers,
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception(
'Erreur chargement documents légaux (${response.statusCode})',
);
}
}
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Réponse invalide');
}
final cgu = decoded['cgu'];
final privacy = decoded['privacy'];
if (cgu is! Map || privacy is! Map) {
throw const FormatException('Documents actifs manquants');
}
return DocumentsActifsInfo(
cgu: DocumentActifInfo.fromJson(Map<String, dynamic>.from(cgu)),
privacy: DocumentActifInfo.fromJson(Map<String, dynamic>.from(privacy)),
);
} catch (e) {
if (e is FormatException) rethrow;
throw Exception(
'Réponse invalide (documents légaux): ${e is Exception ? e.toString() : "format inattendu"}',
);
}
}
/// Utilisateurs en attente de validation (GET /users/pending). Ticket #107.
static Future<List<AppUser>> getPendingUsers({String? role}) async {
final query = role != null ? '?role=$role' : '';
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/pending$query'),
headers: await _headers(),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement comptes en attente (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
return data
.where((e) => e is Map<String, dynamic>)
.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
throw Exception('Réponse invalide (comptes en attente): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// Familles en attente (une entrée par famille). GET /parents/pending-families. Ticket #107.
static Future<List<PendingFamily>> getPendingFamilies() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/pending-families'),
headers: await _headers(),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement familles en attente (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
return data
.where((e) => e is Map)
.map((e) => PendingFamily.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
throw Exception('Réponse invalide (familles en attente): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// 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');
final response = await http.get(
uri,
headers: await _headers(),
);
if (response.statusCode == 404) {
throw Exception('Aucun dossier avec ce numéro.');
}
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement dossier (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw FormatException('Réponse invalide');
}
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
return dossier;
} catch (e) {
if (e is FormatException) rethrow;
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// Valider un utilisateur (AM). PATCH /users/:id/valider. Ticket #108.
static Future<AppUser> validateUser(String userId, {String? comment}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/valider'),
headers: await _headers(),
body: jsonEncode(comment != null ? {'comment': comment} : {}),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur validation (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
}
/// Refuser un compte en attente (AM ou parent). PATCH /users/:id/refuser. Ticket #110.
static Future<AppUser> refuseUser(String userId, {String? comment}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/refuser'),
headers: await _headers(),
body: jsonEncode(
comment != null && comment.trim().isNotEmpty
? {'comment': comment.trim()}
: {},
),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur refus (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
}
/// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentId/valider-dossier'),
headers: await _headers(),
body: jsonEncode(comment != null ? {'comment': comment} : {}),
);
// NestJS renvoie souvent 201 Created sur un POST même avec corps JSON — le front ne doit pas traiter ça comme une erreur
// (sinon validation + emails déjà faits côté serveur mais SnackBar rouge côté UI).
final ok = response.statusCode == 200 || response.statusCode == 201;
if (!ok) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur validation dossier famille (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
final list = data is List ? data : [];
return list.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
// Récupérer la liste des gestionnaires (endpoint dédié)
static Future<List<AppUser>> getGestionnaires() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AppUser.fromJson(e)).toList();
}
static Future<AppUser> createGestionnaire({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
String? relaisId,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
'cguAccepted': true,
'relaisId': relaisId,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
}
throw Exception('Erreur création gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> createAdministrateur({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parents');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => ParentModel.fromJson(e)).toList();
}
static Future<ParentModel> getParent(String userId) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent');
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<ParentModel> updateParentFiche({
required String parentUserId,
required Map<String, dynamic> body,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent'));
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<ParentModel> attachEnfantToParent({
required String parentUserId,
required String enfantId,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<ParentModel> detachEnfantFromParent({
required String parentUserId,
required String enfantId,
}) async {
final response = await http.delete(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
}
if (response.body.isEmpty) {
return getParent(parentUserId);
}
return ParentModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<List<EnfantAdminModel>> getEnfants() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants'));
}
final List<dynamic> data = jsonDecode(response.body);
return data
.whereType<Map<String, dynamic>>()
.map(EnfantAdminModel.fromJson)
.toList();
}
static Future<EnfantAdminModel> getEnfant(String enfantId) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static String _extractErrorMessage(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
return message.join(' - ');
}
return _toStr(message) ?? fallback;
}
} catch (_) {}
return fallback;
}
// Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
}
// Récupérer la liste des administrateurs (via /users filtré ou autre)
// Pour l'instant on va utiliser /users et filtrer côté client si on est super admin
static Future<List<AppUser>> getAdministrateurs() async {
// TODO: Endpoint dédié ou filtrage
// En attendant, on retourne une liste vide ou on tente /users
try {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}'),
headers: await _headers(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => AppUser.fromJson(e))
.where((u) => u.role == 'administrateur' || u.role == 'super_admin')
.toList();
}
} catch (e) {
// On garde un fallback vide pour ne pas bloquer l'UI admin.
}
return [];
}
static Future<AppUser> createAdmin({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création administrateur');
}
throw Exception('Erreur création administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdmin({
required String adminId,
required String nom,
required String prenom,
required String email,
required String telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
};
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> updateGestionnaireRelais({
required String gestionnaireId,
required String? relaisId,
}) async {
final response = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
);
}
}
static Future<AppUser> updateGestionnaire({
required String gestionnaireId,
required String nom,
required String prenom,
required String email,
String? telephone,
required String? relaisId,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'relaisId': relaisId,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification gestionnaire');
}
throw Exception('Erreur modification gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<AppUser> updateAdministrateur({
required String adminId,
required String nom,
required String prenom,
required String email,
String? telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
};
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur modification administrateur');
}
throw Exception('Erreur modification administrateur');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
}
}
}