Compare commits
No commits in common. "e1684676a7a64e602b448c0e7a9f1ce1db8168ac" and "43dabd188561756306c83b8774122d9b7b46009a" have entirely different histories.
e1684676a7
...
43dabd1885
@ -1,21 +1,7 @@
|
||||
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';
|
||||
|
||||
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)', () => {
|
||||
let service: MailService;
|
||||
let sendEmailSpy: jest.SpyInstance;
|
||||
|
||||
@ -2,125 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
/** Ticket #28 — quel template d'email envoyer après validation de compte */
|
||||
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()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly smtpMaxAttempts = 3;
|
||||
private readonly smtpRetryBaseDelayMs = 1000;
|
||||
|
||||
/** Cache des templates Handlebars compilés (fichiers .hbs) */
|
||||
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) {}
|
||||
|
||||
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 {
|
||||
const map: Record<ValidationAccountEmailKind, string> = {
|
||||
parent: 'account-validated-parent',
|
||||
@ -186,50 +81,52 @@ export class MailService {
|
||||
* @param text Contenu texte (optionnel)
|
||||
*/
|
||||
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 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}>`,
|
||||
to,
|
||||
subject,
|
||||
text: text || html.replace(/<[^>]*>?/gm, ''),
|
||||
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
|
||||
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}`);
|
||||
return;
|
||||
} 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.resetTransporter();
|
||||
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
|
||||
* @param to Email du gestionnaire
|
||||
@ -317,29 +214,21 @@ export class MailService {
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
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 safe = (s: string) =>
|
||||
(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||
<p>Votre dossier d'inscription sur <strong>${safe(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;">
|
||||
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
|
||||
<p style="margin: 0;">${commentText}</p>
|
||||
</div>
|
||||
<h2 style="color: #333;">Bonjour ${prenom} ${nom},</h2>
|
||||
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
|
||||
${commentBlock}
|
||||
<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;">
|
||||
<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>
|
||||
<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;">
|
||||
|
||||
@ -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 { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||
import { In, MoreThan, Repository } from "typeorm";
|
||||
@ -332,7 +332,7 @@ export class UserService {
|
||||
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> {
|
||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||
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 } });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
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 expireLe = new Date();
|
||||
expireLe.setDate(expireLe.getDate() + 7);
|
||||
|
||||
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
|
||||
? await this.usersRepository.find({
|
||||
where: {
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: user.numero_dossier,
|
||||
},
|
||||
})
|
||||
: [user];
|
||||
user.statut = StatutUtilisateurType.REFUSE;
|
||||
user.token_reprise = tokenReprise;
|
||||
user.token_reprise_expire_le = expireLe;
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
const usersARefuser = usersDossier.length ? usersDossier : [user];
|
||||
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, {
|
||||
const validation = this.validationRepository.create({
|
||||
user: savedUser,
|
||||
type: 'refus_compte',
|
||||
status: StatutValidationType.REFUSE,
|
||||
validated_by: currentUser,
|
||||
comment,
|
||||
});
|
||||
await manager.save(Validation, validation);
|
||||
}
|
||||
await this.validationRepository.save(validation);
|
||||
|
||||
return updatedUsers;
|
||||
});
|
||||
|
||||
const emailFailures: string[] = [];
|
||||
for (const savedUser of savedUsers) {
|
||||
try {
|
||||
await this.mailService.sendRefusEmail(
|
||||
savedUser.email,
|
||||
@ -401,18 +370,10 @@ export class UserService {
|
||||
tokenReprise,
|
||||
);
|
||||
} catch (err) {
|
||||
emailFailures.push(savedUser.email);
|
||||
this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
|
||||
if (emailFailures.length > 0) {
|
||||
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];
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
class Env {
|
||||
/// Base URL de l’API (sans `/api/v1`).
|
||||
///
|
||||
/// - **`API_BASE_URL` en `--dart-define`** : prioritaire (back local, staging, etc.).
|
||||
/// - **Mode debug sans define** : toujours **`https://app.ptits-pas.fr`** (web inclus) pour
|
||||
/// pointer sur le back de prod depuis l’IDE / `flutter run`.
|
||||
/// - **Web release / profile sans define** : [Uri.base.origin] (même hôte que la page déployée).
|
||||
/// - **Mobile release / profile sans define** : `https://app.ptits-pas.fr`.
|
||||
/// - **Web prod** : par défaut on utilise [Uri.base.origin] (même schéma + hôte que la page),
|
||||
/// ce qui évite les blocages « mixed content » (ex. page en `http://` vs API en `https://`)
|
||||
/// et les builds où `--dart-define=API_BASE_URL` ne serait pas passé.
|
||||
/// - **Web dev** : surcharger avec `--dart-define=API_BASE_URL=http://localhost:3000` (ou l’URL du back).
|
||||
/// - **Mobile** : défaut `https://app.ptits-pas.fr` ou `API_BASE_URL` en `--dart-define`.
|
||||
static String get apiBaseUrl {
|
||||
const fromEnv = String.fromEnvironment('API_BASE_URL');
|
||||
if (fromEnv.isNotEmpty) {
|
||||
return fromEnv;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
return 'https://app.ptits-pas.fr';
|
||||
}
|
||||
if (kIsWeb) {
|
||||
return Uri.base.origin;
|
||||
}
|
||||
|
||||
@ -215,30 +215,6 @@ class UserService {
|
||||
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(
|
||||
|
||||
@ -104,8 +104,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
return 'En attente';
|
||||
case 'suspendu':
|
||||
return 'Suspendu';
|
||||
case 'refuse':
|
||||
return 'Refusé';
|
||||
default:
|
||||
return 'Inconnu';
|
||||
}
|
||||
|
||||
@ -158,13 +158,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
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) {
|
||||
setState(() {
|
||||
|
||||
@ -480,12 +480,15 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
isSubmitting: _submitting,
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (comment == null || comment.trim().isEmpty) return;
|
||||
_refuserEnvoyer(comment.trim());
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Refus (à brancher sur l’API 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -697,12 +697,16 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
isSubmitting: _submitting,
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (comment == null || comment.trim().isEmpty) return;
|
||||
_refuserEnvoyer(comment.trim());
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Refus (à brancher sur l’API 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,15 +8,12 @@ class ValidationRefusForm extends StatefulWidget {
|
||||
/// Retour à l’étape précédente du wizard (écran avec Valider / Refuser).
|
||||
final VoidCallback onPrevious;
|
||||
final ValueChanged<String?> onSubmit;
|
||||
/// Pendant l'appel API : désactive les actions (ticket #110).
|
||||
final bool isSubmitting;
|
||||
|
||||
const ValidationRefusForm({
|
||||
super.key,
|
||||
required this.onCancel,
|
||||
required this.onPrevious,
|
||||
required this.onSubmit,
|
||||
this.isSubmitting = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -70,7 +67,6 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
readOnly: widget.isSubmitting,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
validator: _validateMotifs,
|
||||
@ -95,7 +91,7 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.isSubmitting ? null : widget.onCancel,
|
||||
onPressed: widget.onCancel,
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const Spacer(),
|
||||
@ -103,20 +99,10 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: widget.isSubmitting ? null : widget.onPrevious,
|
||||
onPressed: widget.onPrevious,
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
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(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user