Compare commits

..

No commits in common. "e1684676a7a64e602b448c0e7a9f1ce1db8168ac" and "a4eee819cd7d93e6e3dad3ad449be85cda429a18" have entirely different histories.

10 changed files with 102 additions and 385 deletions

View File

@ -1,21 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { MailService, ValidationAccountEmailKind, isTransientSmtpError } from './mail.service'; import { MailService, ValidationAccountEmailKind } from './mail.service';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
describe('isTransientSmtpError', () => {
it('détecte les erreurs SMTP temporaires (454, coupure auth, timeout)', () => {
expect(isTransientSmtpError(new Error('Invalid login: 454 4.7.0 Temporary authentication failure'))).toBe(true);
expect(isTransientSmtpError(new Error('Connection lost to authentication server'))).toBe(true);
expect(isTransientSmtpError(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))).toBe(true);
expect(isTransientSmtpError(Object.assign(new Error('auth failed'), { responseCode: 454 }))).toBe(true);
});
it('ignore les erreurs permanentes (mauvaise adresse, refus définitif)', () => {
expect(isTransientSmtpError(new Error('Invalid login: 535 Authentication failed'))).toBe(false);
expect(isTransientSmtpError(new Error('Mailbox unavailable'))).toBe(false);
});
});
describe('MailService (ticket #28)', () => { describe('MailService (ticket #28)', () => {
let service: MailService; let service: MailService;
let sendEmailSpy: jest.SpyInstance; let sendEmailSpy: jest.SpyInstance;

View File

@ -2,125 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import * as Handlebars from 'handlebars'; import * as Handlebars from 'handlebars';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { AppConfigService } from '../config/config.service'; import { AppConfigService } from '../config/config.service';
/** Ticket #28 — quel template d'email envoyer après validation de compte */ /** Ticket #28 — quel template d'email envoyer après validation de compte */
export type ValidationAccountEmailKind = 'parent' | 'am'; export type ValidationAccountEmailKind = 'parent' | 'am';
type MailTransporter = {
sendMail: (options: Record<string, unknown>) => Promise<unknown>;
};
/** Erreurs SMTP temporaires (surcharge auth, coupure réseau…) — retentables. */
export function isTransientSmtpError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const code = typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const responseCode =
typeof error === 'object' && error !== null && 'responseCode' in error
? Number((error as { responseCode?: unknown }).responseCode)
: undefined;
if (responseCode === 454 || responseCode === 421 || responseCode === 450 || responseCode === 451) {
return true;
}
const transientCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ECONNREFUSED', 'EPIPE']);
if (transientCodes.has(code)) {
return true;
}
const transientPatterns = [
/temporary authentication failure/i,
/connection lost to authentication server/i,
/connection reset/i,
/timeout/i,
/try again later/i,
];
return transientPatterns.some((pattern) => pattern.test(message));
}
@Injectable() @Injectable()
export class MailService { export class MailService {
private readonly logger = new Logger(MailService.name); private readonly logger = new Logger(MailService.name);
private readonly smtpMaxAttempts = 3;
private readonly smtpRetryBaseDelayMs = 1000;
/** Cache des templates Handlebars compilés (fichiers .hbs) */ /** Cache des templates Handlebars compilés (fichiers .hbs) */
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>(); private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
/** Transporteur SMTP réutilisé (évite une auth TCP/SASL par email). */
private transporter: MailTransporter | null = null;
private transporterConfigKey: string | null = null;
constructor(private readonly configService: AppConfigService) {} constructor(private readonly configService: AppConfigService) {}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private buildSmtpTransportConfig(): SMTPTransport.Options {
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const transportConfig: SMTPTransport.Options = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
pool: true,
maxConnections: 1,
maxMessages: 100,
};
if (smtpAuthRequired && smtpUser && smtpPassword) {
transportConfig.auth = {
user: smtpUser,
pass: smtpPassword,
};
}
return transportConfig;
}
private getSmtpConfigKey(config: SMTPTransport.Options): string {
const auth = config.auth as { user?: string; pass?: string } | undefined;
return JSON.stringify({
host: config.host,
port: config.port,
secure: config.secure,
user: auth?.user ?? '',
pass: auth?.pass ?? '',
});
}
private resetTransporter(): void {
const current = this.transporter as { close?: () => void } | null;
current?.close?.();
this.transporter = null;
this.transporterConfigKey = null;
}
private async getTransporter(): Promise<MailTransporter> {
const transportConfig = this.buildSmtpTransportConfig();
const configKey = this.getSmtpConfigKey(transportConfig);
if (this.transporter && this.transporterConfigKey === configKey) {
return this.transporter;
}
this.resetTransporter();
const nodemailer = await import('nodemailer');
this.transporter = nodemailer.createTransport(transportConfig) as MailTransporter;
this.transporterConfigKey = configKey;
return this.transporter;
}
private templateBasename(kind: ValidationAccountEmailKind): string { private templateBasename(kind: ValidationAccountEmailKind): string {
const map: Record<ValidationAccountEmailKind, string> = { const map: Record<ValidationAccountEmailKind, string> = {
parent: 'account-validated-parent', parent: 'account-validated-parent',
@ -186,50 +81,52 @@ export class MailService {
* @param text Contenu texte (optionnel) * @param text Contenu texte (optionnel)
*/ */
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> { async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
try {
// Récupération de la configuration SMTP
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const emailFromName = this.configService.get<string>('email_from_name'); const emailFromName = this.configService.get<string>('email_from_name');
const emailFromAddress = this.configService.get<string>('email_from_address'); const emailFromAddress = this.configService.get<string>('email_from_address');
const mailOptions = {
// Import dynamique de nodemailer
const nodemailer = await import('nodemailer');
// Configuration du transporteur
const transportConfig: any = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
};
if (smtpAuthRequired && smtpUser && smtpPassword) {
transportConfig.auth = {
user: smtpUser,
pass: smtpPassword,
};
}
const transporter = nodemailer.createTransport(transportConfig);
// Envoi de l'email
await transporter.sendMail({
from: `"${emailFromName}" <${emailFromAddress}>`, from: `"${emailFromName}" <${emailFromAddress}>`,
to, to,
subject, subject,
text: text || html.replace(/<[^>]*>?/gm, ''), text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
html, html,
}; });
let lastError: unknown;
for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) {
try {
const transporter = await this.getTransporter();
await transporter.sendMail(mailOptions);
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`); this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
return;
} catch (error) { } catch (error) {
lastError = error;
const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error);
if (canRetry) {
const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1);
this.logger.warn(
`SMTP temporairement indisponible pour ${to} (tentative ${attempt}/${this.smtpMaxAttempts}), nouvel essai dans ${delayMs}ms`,
error instanceof Error ? error.message : error,
);
this.resetTransporter();
await this.sleep(delayMs);
continue;
}
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error); this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
this.resetTransporter();
throw error; throw error;
} }
} }
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
this.resetTransporter();
throw lastError;
}
/** /**
* Envoi de l'email de bienvenue pour un gestionnaire * Envoi de l'email de bienvenue pour un gestionnaire
* @param to Email du gestionnaire * @param to Email du gestionnaire
@ -317,29 +214,21 @@ export class MailService {
token: string, token: string,
): Promise<void> { ): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas"); const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`; const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre dossier compléments demandés`; const subject = `Votre dossier compléments demandés`;
const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.'); const commentBlock = comment
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const html = ` const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2> <h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p> <p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;"> ${commentBlock}
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
<p style="margin: 0;">${commentText}</p>
</div>
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p> <p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0;">
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a> <a href="${repriseLink}" style="background-color: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
</div> </div>
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p> <p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;"> <hr style="border: 1px solid #eee; margin: 20px 0;">

View File

@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, MoreThan, Repository } from "typeorm"; import { In, MoreThan, Repository } from "typeorm";
@ -332,7 +332,7 @@ export class UserService {
return savedUser; return savedUser;
} }
/** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. Ticket #110 */ /** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> { async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
@ -340,58 +340,27 @@ export class UserService {
const user = await this.usersRepository.findOne({ where: { id: user_id } }); const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable'); if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
throw new BadRequestException('Seul un dossier en attente peut être refusé.'); throw new BadRequestException('Seul un compte en attente peut être refusé.');
} }
const tokenReprise = crypto.randomUUID(); const tokenReprise = crypto.randomUUID();
const expireLe = new Date(); const expireLe = new Date();
expireLe.setDate(expireLe.getDate() + 7); expireLe.setDate(expireLe.getDate() + 7);
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier user.statut = StatutUtilisateurType.REFUSE;
? await this.usersRepository.find({ user.token_reprise = tokenReprise;
where: { user.token_reprise_expire_le = expireLe;
role: RoleType.PARENT, const savedUser = await this.usersRepository.save(user);
numero_dossier: user.numero_dossier,
},
})
: [user];
const usersARefuser = usersDossier.length ? usersDossier : [user]; const validation = this.validationRepository.create({
const utilisateurInvalide = usersARefuser.find(
(u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
);
if (utilisateurInvalide) {
throw new BadRequestException(
'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
);
}
const savedUsers = await this.usersRepository.manager.transaction(async (manager) => {
const updatedUsers: Users[] = [];
for (const userARefuser of usersARefuser) {
userARefuser.statut = StatutUtilisateurType.REFUSE;
userARefuser.token_reprise = tokenReprise;
userARefuser.token_reprise_expire_le = expireLe;
const savedUser = await manager.save(Users, userARefuser);
updatedUsers.push(savedUser);
const validation = manager.create(Validation, {
user: savedUser, user: savedUser,
type: 'refus_compte', type: 'refus_compte',
status: StatutValidationType.REFUSE, status: StatutValidationType.REFUSE,
validated_by: currentUser, validated_by: currentUser,
comment, comment,
}); });
await manager.save(Validation, validation); await this.validationRepository.save(validation);
}
return updatedUsers;
});
const emailFailures: string[] = [];
for (const savedUser of savedUsers) {
try { try {
await this.mailService.sendRefusEmail( await this.mailService.sendRefusEmail(
savedUser.email, savedUser.email,
@ -401,18 +370,10 @@ export class UserService {
tokenReprise, tokenReprise,
); );
} catch (err) { } catch (err) {
emailFailures.push(savedUser.email); this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err);
}
} }
if (emailFailures.length > 0) { return savedUser;
throw new ServiceUnavailableException(
`Le dossier a bien été refusé en base, mais l'envoi d'email a échoué pour : ${emailFailures.join(', ')}. Réessayez ou contactez le support.`,
);
}
return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0];
} }
/** /**

View File

@ -1,21 +1,18 @@
import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
class Env { class Env {
/// Base URL de lAPI (sans `/api/v1`). /// Base URL de lAPI (sans `/api/v1`).
/// ///
/// - **`API_BASE_URL` en `--dart-define`** : prioritaire (back local, staging, etc.). /// - **Web prod** : par défaut on utilise [Uri.base.origin] (même schéma + hôte que la page),
/// - **Mode debug sans define** : toujours **`https://app.ptits-pas.fr`** (web inclus) pour /// ce qui évite les blocages « mixed content » (ex. page en `http://` vs API en `https://`)
/// pointer sur le back de prod depuis lIDE / `flutter run`. /// et les builds `--dart-define=API_BASE_URL` ne serait pas passé.
/// - **Web release / profile sans define** : [Uri.base.origin] (même hôte que la page déployée). /// - **Web dev** : surcharger avec `--dart-define=API_BASE_URL=http://localhost:3000` (ou lURL du back).
/// - **Mobile release / profile sans define** : `https://app.ptits-pas.fr`. /// - **Mobile** : défaut `https://app.ptits-pas.fr` ou `API_BASE_URL` en `--dart-define`.
static String get apiBaseUrl { static String get apiBaseUrl {
const fromEnv = String.fromEnvironment('API_BASE_URL'); const fromEnv = String.fromEnvironment('API_BASE_URL');
if (fromEnv.isNotEmpty) { if (fromEnv.isNotEmpty) {
return fromEnv; return fromEnv;
} }
if (kDebugMode) {
return 'https://app.ptits-pas.fr';
}
if (kIsWeb) { if (kIsWeb) {
return Uri.base.origin; return Uri.base.origin;
} }

View File

@ -215,30 +215,6 @@ class UserService {
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {})); 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. /// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async { static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
final response = await http.post( final response = await http.post(

View File

@ -104,8 +104,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
return 'En attente'; return 'En attente';
case 'suspendu': case 'suspendu':
return 'Suspendu'; return 'Suspendu';
case 'refuse':
return 'Refusé';
default: default:
return 'Inconnu'; return 'Inconnu';
} }

View File

@ -158,13 +158,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
child: Text('Suspendu', style: TextStyle(fontSize: 12)), child: Text('Suspendu', style: TextStyle(fontSize: 12)),
), ),
), ),
DropdownMenuItem<String?>(
value: 'refuse',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Refusé', style: TextStyle(fontSize: 12)),
),
),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {

View File

@ -480,12 +480,15 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
children: [ children: [
Expanded( Expanded(
child: ValidationRefusForm( child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose, onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false), onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) { onSubmit: (comment) {
if (comment == null || comment.trim().isEmpty) return; if (!mounted) return;
_refuserEnvoyer(comment.trim()); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refus (à brancher sur lAPI refus)')),
);
widget.onClose();
}, },
), ),
), ),
@ -493,34 +496,4 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
), ),
); );
} }
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
} }

View File

@ -697,12 +697,16 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
children: [ children: [
Expanded( Expanded(
child: ValidationRefusForm( child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose, onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false), onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) { onSubmit: (comment) {
if (comment == null || comment.trim().isEmpty) return; if (!mounted) return;
_refuserEnvoyer(comment.trim()); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Refus (à brancher sur lAPI refus ticket #110)')),
);
widget.onClose();
}, },
), ),
), ),
@ -710,50 +714,4 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
), ),
); );
} }
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
final parentId = _firstParentId?.trim();
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _submitting = true);
try {
await UserService.refuseUser(parentId, comment: comment);
if (!mounted) return;
final nbEnAttente = widget.dossier.parents
.where((p) => p.statut == 'en_attente')
.length;
final msg = nbEnAttente > 1
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
} }

View File

@ -8,15 +8,12 @@ class ValidationRefusForm extends StatefulWidget {
/// Retour à létape précédente du wizard (écran avec Valider / Refuser). /// Retour à létape précédente du wizard (écran avec Valider / Refuser).
final VoidCallback onPrevious; final VoidCallback onPrevious;
final ValueChanged<String?> onSubmit; final ValueChanged<String?> onSubmit;
/// Pendant l'appel API : désactive les actions (ticket #110).
final bool isSubmitting;
const ValidationRefusForm({ const ValidationRefusForm({
super.key, super.key,
required this.onCancel, required this.onCancel,
required this.onPrevious, required this.onPrevious,
required this.onSubmit, required this.onSubmit,
this.isSubmitting = false,
}); });
@override @override
@ -70,7 +67,6 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
), ),
child: TextFormField( child: TextFormField(
controller: _controller, controller: _controller,
readOnly: widget.isSubmitting,
maxLines: null, maxLines: null,
minLines: 1, minLines: 1,
validator: _validateMotifs, validator: _validateMotifs,
@ -95,7 +91,7 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
Row( Row(
children: [ children: [
TextButton( TextButton(
onPressed: widget.isSubmitting ? null : widget.onCancel, onPressed: widget.onCancel,
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
const Spacer(), const Spacer(),
@ -103,20 +99,10 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton( TextButton(
onPressed: widget.isSubmitting ? null : widget.onPrevious, onPressed: widget.onPrevious,
child: const Text('Précédent'), child: const Text('Précédent'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (widget.isSubmitting)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
ElevatedButton( ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle, style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () { onPressed: () {