Merge develop into master for #167

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 00:49:02 +02:00
co-authored by Cursor
17 changed files with 982 additions and 5 deletions
+28
View File
@@ -0,0 +1,28 @@
Stack trace:
Frame Function Args
0007FFFFAC10 00021005FEBA (000210285F48, 00021026AB6E, 000000000000, 0007FFFF9B10) msys-2.0.dll+0x1FEBA
0007FFFFAC10 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFAEE8) msys-2.0.dll+0x67F9
0007FFFFAC10 000210046832 (000210285FF9, 0007FFFFAAC8, 000000000000, 000000000000) msys-2.0.dll+0x6832
0007FFFFAC10 000210068F86 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28F86
0007FFFFAC10 0002100690B4 (0007FFFFAC20, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x290B4
0007FFFFAEF0 00021006A49D (0007FFFFAC20, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A49D
End of stack trace
Loaded modules:
000100400000 bash.exe
7FFE2CD00000 ntdll.dll
7FFE2C1F0000 KERNEL32.DLL
7FFE29C90000 KERNELBASE.dll
7FFE2B8C0000 USER32.dll
7FFE2A090000 win32u.dll
7FFE2C410000 GDI32.dll
7FFE29730000 gdi32full.dll
7FFE29860000 msvcp_win.dll
7FFE2A930000 ucrtbase.dll
000210040000 msys-2.0.dll
7FFE2C350000 advapi32.dll
7FFE2B810000 msvcrt.dll
7FFE2CC10000 sechost.dll
7FFE2AB30000 RPCRT4.dll
7FFE28D10000 CRYPTBASE.DLL
7FFE29B50000 bcryptPrimitives.dll
7FFE2CAD0000 IMM32.DLL
Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+113
View File
@@ -0,0 +1,113 @@
/// Modèles du couple de garde (enfant ↔ AM) — ticket #167.
/// Contrat backend #168 : GET /parents/me/couples-garde.
/// Identité minimale d'une personne du couple (enfant, AM ou parent).
class CoupleMembre {
final String id;
final String? prenom;
final String? nom;
final String? photoUrl;
const CoupleMembre({
required this.id,
this.prenom,
this.nom,
this.photoUrl,
});
/// Nom d'affichage : prénom seul si dispo, sinon « Prénom Nom », sinon repli.
String displayName({String fallback = ''}) {
final p = (prenom ?? '').trim();
final n = (nom ?? '').trim();
if (p.isNotEmpty && n.isNotEmpty) return '$p $n';
if (p.isNotEmpty) return p;
if (n.isNotEmpty) return n;
return fallback;
}
factory CoupleMembre.fromJson(Map<String, dynamic> json) {
return CoupleMembre(
id: (json['id'] ?? '').toString(),
prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(),
photoUrl: json['photo_url']?.toString(),
);
}
}
/// Un couple de garde = placement actif enfant ↔ AM.
class CoupleGarde {
final String id;
final CoupleMembre enfant;
final CoupleMembre am;
final bool courant;
const CoupleGarde({
required this.id,
required this.enfant,
required this.am,
this.courant = false,
});
factory CoupleGarde.fromJson(Map<String, dynamic> json) {
return CoupleGarde(
id: (json['id'] ?? '').toString(),
enfant: CoupleMembre.fromJson(
Map<String, dynamic>.from(json['enfant'] ?? const {}),
),
am: CoupleMembre.fromJson(
Map<String, dynamic>.from(json['am'] ?? const {}),
),
courant: json['courant'] == true,
);
}
CoupleGarde copyWith({bool? courant}) {
return CoupleGarde(
id: id,
enfant: enfant,
am: am,
courant: courant ?? this.courant,
);
}
}
/// Réponse de l'API couples de garde (liste + id du couple courant).
class CouplesGardeResponse {
final List<CoupleGarde> couples;
final String? coupleCourantId;
const CouplesGardeResponse({
required this.couples,
this.coupleCourantId,
});
bool get isEmpty => couples.isEmpty;
bool get isUnique => couples.length == 1;
/// Couple courant : celui marqué `courant`, sinon celui de [coupleCourantId],
/// sinon le premier (repli implicite côté client, prévu par le back).
CoupleGarde? get coupleCourant {
if (couples.isEmpty) return null;
for (final c in couples) {
if (c.courant) return c;
}
if (coupleCourantId != null) {
for (final c in couples) {
if (c.id == coupleCourantId) return c;
}
}
return couples.first;
}
factory CouplesGardeResponse.fromJson(Map<String, dynamic> json) {
final list = (json['couples'] as List?) ?? const [];
return CouplesGardeResponse(
couples: list
.whereType<Map>()
.map((e) => CoupleGarde.fromJson(Map<String, dynamic>.from(e)))
.toList(),
coupleCourantId: json['couple_courant_id']?.toString(),
);
}
}
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/couple_garde.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/couple_garde_service.dart';
import 'package:p_tits_pas/widgets/quotidien/couple_selector_bandeau.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_shell.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Tableau de bord parent — coquille 3 colonnes quotidien (#166).
/// Colonne gauche : sélecteur de couple enfantnounou (#167).
/// Métier cartes / blog / messagerie : tickets C/D/E.
class ParentDashboardScreen extends StatefulWidget {
const ParentDashboardScreen({super.key});
@@ -17,10 +21,16 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
QuotidienNavSection _section = QuotidienNavSection.liaison;
AppUser? _user;
List<CoupleGarde> _couples = const [];
String? _selectedCoupleId;
bool _couplesLoading = true;
String? _couplesError;
@override
void initState() {
super.initState();
_loadUser();
_loadCouples();
}
Future<void> _loadUser() async {
@@ -28,6 +38,52 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
if (mounted) setState(() => _user = user);
}
Future<void> _loadCouples() async {
setState(() {
_couplesLoading = true;
_couplesError = null;
});
try {
final res = await CoupleGardeService.getCouplesGarde();
if (!mounted) return;
setState(() {
_couples = res.couples;
_selectedCoupleId = res.coupleCourant?.id;
_couplesLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_couplesError = e.toString().replaceFirst('Exception: ', '');
_couplesLoading = false;
});
}
}
Future<void> _selectCouple(CoupleGarde couple) async {
if (couple.id == _selectedCoupleId) return;
// Optimiste : on bascule tout de suite, l'API persiste ensuite.
setState(() => _selectedCoupleId = couple.id);
try {
final res = await CoupleGardeService.definirCoupleCourant(couple.id);
if (!mounted) return;
setState(() {
_couples = res.couples;
_selectedCoupleId = res.coupleCourant?.id ?? couple.id;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Impossible de changer de garde : '
'${e.toString().replaceFirst('Exception: ', '')}',
),
),
);
}
}
String get _displayName {
final n = _user?.fullName.trim() ?? '';
if (n.isNotEmpty) return n;
@@ -52,11 +108,13 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
onProfileTap: () => _soon('Profil'),
onSearchAmTap: () => _soon('Recherche AM'),
onSettingsTap: () => _soon('Paramètres'),
leftColumn: const QuotidienColumnPlaceholder(
title: 'Cartes',
subtitle:
'Couple enfantnounou et flux de cartes\n(à brancher — tickets #167 / #173).',
icon: Icons.style_outlined,
leftColumn: _LeftColumn(
couples: _couples,
selectedCoupleId: _selectedCoupleId,
loading: _couplesLoading,
error: _couplesError,
onRetry: _loadCouples,
onCoupleSelected: _selectCouple,
),
centerColumn: const QuotidienColumnPlaceholder(
title: 'Blog',
@@ -81,3 +139,51 @@ class _ParentDashboardScreenState extends State<ParentDashboardScreen> {
);
}
}
/// Colonne gauche : bandeau couple (#167) puis flux de cartes (#173 à venir).
class _LeftColumn extends StatelessWidget {
final List<CoupleGarde> couples;
final String? selectedCoupleId;
final bool loading;
final String? error;
final VoidCallback onRetry;
final ValueChanged<CoupleGarde> onCoupleSelected;
const _LeftColumn({
required this.couples,
required this.selectedCoupleId,
required this.loading,
required this.error,
required this.onRetry,
required this.onCoupleSelected,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CoupleSelectorBandeau(
couples: couples,
selectedCoupleId: selectedCoupleId,
loading: loading,
errorMessage: error,
onRetry: onRetry,
onCoupleSelected: onCoupleSelected,
),
const SizedBox(height: 14),
const Expanded(
child: QuotidienColumnPlaceholder(
title: 'Cartes',
subtitle:
'Absences, congés AM, sorties à valider\n(à brancher — ticket #173).',
icon: Icons.style_outlined,
),
),
],
),
);
}
}
@@ -62,6 +62,10 @@ class ApiConfig {
static const String parents = '/parents';
/// Création dossier famille actif par le staff (#129) — body type register parent.
static const String parentsDossier = '/parents/dossier';
/// Couples de garde du parent connecté (#167 / #168).
static const String parentsCouplesGarde = '/parents/me/couples-garde';
static const String parentsCoupleGardeCourant =
'/parents/me/couples-garde/courant';
static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM.
static const String assistantesMaternellesDossier =
@@ -0,0 +1,76 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/couple_garde.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
/// Accès API aux couples de garde du parent connecté — tickets #167 / #168.
/// - GET /parents/me/couples-garde
/// - PUT /parents/me/couples-garde/courant { couple_id }
class CoupleGardeService {
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 _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
if (message is Map && message['message'] is String) {
return message['message'] as String;
}
}
} catch (_) {}
return fallback;
}
/// Liste les couples de garde et le couple courant du parent connecté.
static Future<CouplesGardeResponse> getCouplesGarde() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCouplesGarde}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement des couples de garde'),
);
}
final decoded = jsonDecode(response.body);
return CouplesGardeResponse.fromJson(
Map<String, dynamic>.from(decoded as Map),
);
}
/// Persiste le couple courant (préférence utilisateur) et renvoie la liste
/// à jour.
static Future<CouplesGardeResponse> definirCoupleCourant(
String coupleId,
) async {
final response = await http.put(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsCoupleGardeCourant}'),
headers: await _headers(),
body: jsonEncode({'couple_id': coupleId}),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur sélection du couple de garde'),
);
}
final decoded = jsonDecode(response.body);
return CouplesGardeResponse.fromJson(
Map<String, dynamic>.from(decoded as Map),
);
}
}
@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/couple_garde.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
/// Rôle qui consulte le bandeau : côté parent (enfant | nounou) ou côté AM
/// (enfant | parent(s)). Le composant est le même, seule la lecture change.
/// Ticket #167 (parent) ; #170 réutilise en mode AM.
enum CoupleBandeauMode { parent, assistanteMaternelle }
/// Bandeau « couple de garde » en haut de la colonne gauche du TdB.
///
/// - Plusieurs couples → contrôle unique avec chevron (dropdown de bascule).
/// - Un seul couple → affichage informatif (pas de chevron, pas de menu).
/// - Aucun couple → état vide discret.
class CoupleSelectorBandeau extends StatelessWidget {
final CoupleBandeauMode mode;
final List<CoupleGarde> couples;
final String? selectedCoupleId;
final ValueChanged<CoupleGarde>? onCoupleSelected;
final bool loading;
final String? errorMessage;
final VoidCallback? onRetry;
const CoupleSelectorBandeau({
super.key,
this.mode = CoupleBandeauMode.parent,
required this.couples,
this.selectedCoupleId,
this.onCoupleSelected,
this.loading = false,
this.errorMessage,
this.onRetry,
});
CoupleGarde? get _selected {
if (couples.isEmpty) return null;
if (selectedCoupleId != null) {
for (final c in couples) {
if (c.id == selectedCoupleId) return c;
}
}
for (final c in couples) {
if (c.courant) return c;
}
return couples.first;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (loading) return const _CoupleBandeauSkeleton();
if (errorMessage != null) {
return _CoupleBandeauError(message: errorMessage!, onRetry: onRetry);
}
if (couples.isEmpty) return const _CoupleBandeauEmpty();
final selected = _selected!;
final multi = couples.length > 1;
final card = _CoupleCard(
mode: mode,
couple: selected,
showChevron: multi,
colorIndex: couples.indexOf(selected),
);
if (!multi) return card;
return Theme(
data: Theme.of(context).copyWith(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
focusColor: Colors.transparent,
),
child: PopupMenuButton<String>(
tooltip: 'Changer de garde',
// L'offset doit être suffisant pour descendre sous la carte (qui fait 90px).
offset: const Offset(0, 95),
color: Colors.transparent, // Le fond devient invisible
elevation: 0, // Pas d'ombre carrée
constraints: BoxConstraints.tightFor(width: constraints.maxWidth),
padding: EdgeInsets.zero,
onSelected: (id) {
final chosen = couples.firstWhere((c) => c.id == id);
onCoupleSelected?.call(chosen);
},
itemBuilder: (context) => [
for (final c in couples)
if (c.id != selected.id)
PopupMenuItem<String>(
value: c.id,
padding: EdgeInsets.zero,
height: 100, // Hauteur de la carte (90) + un peu de marge (10)
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CoupleCard(
mode: mode,
couple: c,
showChevron: false,
selected: false,
isMenuItem: true,
colorIndex: couples.indexOf(c),
),
),
),
],
child: card,
),
);
});
}
}
/// Carte principale : enfant à gauche, séparateur, AM/parents à droite.
class _CoupleCard extends StatelessWidget {
final CoupleBandeauMode mode;
final CoupleGarde couple;
final bool showChevron;
final bool selected;
final bool isMenuItem;
final int colorIndex;
const _CoupleCard({
required this.mode,
required this.couple,
required this.showChevron,
this.selected = true,
this.isMenuItem = false,
required this.colorIndex,
});
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.bandeauColors[colorIndex % QuotidienTheme.bandeauColors.length]),
fit: BoxFit.fill,
),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: _MembreTile(
photoUrl: couple.enfant.photoUrl,
name: (couple.enfant.prenom != null && couple.enfant.prenom!.isNotEmpty)
? couple.enfant.prenom!
: couple.enfant.displayName(fallback: 'Enfant'),
fallbackIcon: Icons.child_care,
),
),
),
Container(
width: 8,
height: 44,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.pencilLineVerticalAsset),
fit: BoxFit.contain,
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: _MembreTile(
photoUrl: couple.am.photoUrl,
name: (couple.am.prenom != null && couple.am.prenom!.isNotEmpty)
? couple.am.prenom!
: couple.am.displayName(
fallback: mode == CoupleBandeauMode.parent
? 'Nounou'
: 'Parent',
),
fallbackIcon: mode == CoupleBandeauMode.parent
? Icons.volunteer_activism
: Icons.person_outline,
),
),
),
SizedBox(
width: 28,
child: showChevron
? const Icon(
Icons.keyboard_arrow_down,
color: QuotidienTheme.ink,
)
: null,
),
],
),
);
}
}
/// Photo ronde + nom (une moitié du couple).
class _MembreTile extends StatelessWidget {
final String? photoUrl;
final String name;
final IconData fallbackIcon;
const _MembreTile({
required this.photoUrl,
required this.name,
required this.fallbackIcon,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_Avatar(photoUrl: photoUrl, fallbackIcon: fallbackIcon, size: 64),
const SizedBox(width: 12),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.merienda(
fontSize: 22,
fontWeight: FontWeight.bold,
color: QuotidienTheme.ink,
),
),
),
],
);
}
}
class _Avatar extends StatelessWidget {
final String? photoUrl;
final IconData fallbackIcon;
final double size;
const _Avatar({
required this.photoUrl,
required this.fallbackIcon,
required this.size,
});
@override
Widget build(BuildContext context) {
final url = ApiConfig.absoluteMediaUrl(photoUrl);
final placeholder = Container(
width: size,
height: size,
color: QuotidienTheme.lavender.withValues(alpha: 0.35),
child: Icon(fallbackIcon, size: size * 0.5, color: QuotidienTheme.ink),
);
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: url.isEmpty
? placeholder
: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder,
),
),
);
}
}
class _CoupleBandeauSkeleton extends StatelessWidget {
const _CoupleBandeauSkeleton();
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: BoxDecoration(
image: DecorationImage(
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant
fit: BoxFit.fill,
colorFilter: ColorFilter.mode(
Colors.white.withValues(alpha: 0.5),
BlendMode.lighten,
),
),
),
alignment: Alignment.center,
child: const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
}
class _CoupleBandeauEmpty extends StatelessWidget {
const _CoupleBandeauEmpty();
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage(QuotidienTheme.bandeauLime), // Un asset existant par défaut
fit: BoxFit.fill,
),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
const Icon(Icons.info_outline, color: QuotidienTheme.muted),
const SizedBox(width: 12),
Expanded(
child: Text(
'Aucune garde active pour le moment.',
style: GoogleFonts.merriweather(
fontSize: 14,
color: QuotidienTheme.muted,
),
),
),
],
),
);
}
}
class _CoupleBandeauError extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const _CoupleBandeauError({required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
return Container(
height: 90,
decoration: BoxDecoration(
image: DecorationImage(
image: const AssetImage(QuotidienTheme.bandeauLime), // Un asset existant
fit: BoxFit.fill,
colorFilter: ColorFilter.mode(
QuotidienTheme.coral.withValues(alpha: 0.3),
BlendMode.srcATop,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
const Icon(Icons.error_outline, color: QuotidienTheme.ink),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: GoogleFonts.merriweather(
fontSize: 13,
color: QuotidienTheme.ink,
),
),
),
if (onRetry != null)
TextButton(
onPressed: onRetry,
child: const Text('Réessayer'),
),
],
),
);
}
}
@@ -19,6 +19,29 @@ abstract final class QuotidienTheme {
/// couleur charte par section ; l'inactive est la même, plus transparente.
static const double pillInactiveOpacity = 0.45;
static const String pillIvoryAsset = 'assets/images/bg_ivoire_pill.png';
static const String pillBlueAsset = 'assets/images/bg_blue_pill.png';
// Bandeaux pour les couples (ratio 10:1, dessinés au crayon, couleur dynamique)
static const String bandeauLime = 'assets/images/bandeau_lime.png';
static const String bandeauBlue = 'assets/images/bandeau_blue.png';
static const String bandeauPeach = 'assets/images/bandeau_peach.png';
static const String bandeauYellow = 'assets/images/bandeau_yellow.png';
static const String bandeauLavender = 'assets/images/bandeau_lavender.png';
static const List<String> bandeauColors = [
bandeauLime,
bandeauBlue,
bandeauPeach,
bandeauYellow,
bandeauLavender,
];
/// Retourne un asset bandeau fixe pour un couple donné (basé sur son ID)
static String bandeauAssetForCouple(String coupleId) {
if (coupleId.isEmpty) return bandeauLime;
final hash = coupleId.hashCode.abs();
return bandeauColors[hash % bandeauColors.length];
}
static const String pillYellowAsset = 'assets/images/bg_yellow_pill.png';
static const String pillPeachAsset = 'assets/images/bg_peach_pill.png';
static const String pillTurquoiseAsset =
@@ -0,0 +1,144 @@
/**
* Liste les issues Gitea ouvertes pour un milestone donné (ex. 0.1.0).
* Usage : node scripts/gitea-list-open-issues-by-milestone.js [milestone]
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '..');
const REPO = 'jmartin/petitspas';
const milestoneWanted = (process.argv[2] || '0.1.0').trim();
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
function getJson(apiPath) {
return new Promise((resolve, reject) => {
const opts = {
hostname: 'git.ptits-pas.fr',
path: `/api/v1/repos/${REPO}${apiPath}`,
method: 'GET',
headers: {
Authorization: 'token ' + token,
Accept: 'application/json',
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${d.slice(0, 500)}`));
return;
}
try {
resolve(JSON.parse(d));
} catch (e) {
reject(e);
}
});
});
req.on('error', reject);
req.end();
});
}
async function fetchAllOpenIssues() {
const out = [];
let page = 1;
const limit = 50;
for (;;) {
const qs = new URLSearchParams({
state: 'open',
type: 'all',
page: String(page),
limit: String(limit),
});
const batch = await getJson(`/issues?${qs}`);
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
page += 1;
if (page > 40) break;
}
return out;
}
function milestoneMatches(m, wanted) {
if (!m) return false;
const t = (m.title || '').trim();
return t === wanted || t === `v${wanted}`;
}
async function main() {
let milestones;
try {
milestones = await getJson('/milestones?state=all');
} catch (e) {
milestones = [];
console.warn('Milestones non lisibles:', e.message);
}
const known = Array.isArray(milestones)
? milestones.map((m) => m.title).filter(Boolean)
: [];
if (known.length) {
console.log('Milestones connus sur le dépôt :', known.join(', '));
}
const issues = await fetchAllOpenIssues();
const filtered = issues.filter((i) => milestoneMatches(i.milestone, milestoneWanted));
console.log('');
console.log(`## Issues ouvertes — milestone « ${milestoneWanted} » (${filtered.length})`);
console.log('');
if (filtered.length === 0) {
console.log(
'Aucune issue ouverte avec ce milestone. Vérifier sur Gitea que les tickets ' +
'0.1.0 portent bien le milestone, ou élargir la requête.',
);
console.log('');
console.log(`(Total issues ouvertes sans filtre milestone : ${issues.length})`);
const withM = issues.filter((i) => i.milestone);
if (withM.length) {
console.log('');
console.log('Issues ouvertes qui ont *un* milestone :');
for (const i of withM) {
console.log(`- #${i.number} [${i.milestone.title}] ${i.title}`);
}
}
process.exit(0);
}
for (const i of filtered.sort((a, b) => a.number - b.number)) {
const labels = (i.labels || []).map((l) => l.name).join(', ');
console.log(`- **#${i.number}** — ${i.title}`);
if (labels) console.log(` - Labels : ${labels}`);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
@@ -0,0 +1,103 @@
/**
* POST /api/v1/auth/register/parent — foyer mono-parent (1 parent + 1 enfant).
* Inspiré de register-parent-lecomte-test.mjs.
* Email : sophie.bernard@example.com
*
* Usage : node tests/scripts/register-parent-bernard-test.mjs [BASE_URL]
*/
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
function toDataUri(filePath) {
const buf = fs.readFileSync(filePath);
return `data:image/png;base64,${buf.toString('base64')}`;
}
const presentationDossier =
"Je suis Sophie BERNARD, mère isolée de Jules. J'ai la garde complète de mon fils. " +
"Je recherche une assistante maternelle bienveillante à Bezons. " +
"Merci pour l'étude de notre dossier.";
const body = {
email: 'sophie.bernard@example.com',
prenom: 'Sophie',
nom: 'BERNARD',
telephone: '0611223344',
adresse: '12 Rue des Lilas',
code_postal: '95870',
ville: 'Bezons',
// Pas de co-parent (mono-parent).
enfants: [
{
prenom: 'Jules',
nom: 'BERNARD',
date_naissance: '2024-06-10',
genre: 'H',
// Réutilise une photo de test existante.
photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')),
photo_filename: 'jules_bernard.png',
},
],
presentation_dossier: presentationDossier,
acceptation_cgu: true,
acceptation_privacy: true,
};
const json = JSON.stringify(body);
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`);
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'Content-Length': Buffer.byteLength(json, 'utf8'),
},
};
const lib = url.protocol === 'https:' ? https : http;
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
const req = lib.request(opts, (res) => {
let data = '';
res.on('data', (c) => {
data += c;
});
res.on('end', () => {
console.log('HTTP', res.statusCode);
try {
const j = JSON.parse(data);
console.log(JSON.stringify(j, null, 2));
} catch {
console.log(data.slice(0, 4000));
}
if (res.statusCode < 200 || res.statusCode >= 300) process.exit(1);
});
});
req.on('error', (e) => {
console.error('Erreur réseau:', e.message);
process.exit(1);
});
req.setTimeout(120000, () => {
req.destroy();
console.error('Timeout 120s');
process.exit(1);
});
req.write(json);
req.end();