Compare commits

...

14 Commits

Author SHA1 Message Date
e1684676a7 fix(mail): retry SMTP transient + erreur API si email refus échoue
- Transporteur SMTP réutilisé (pool) au lieu d'une auth par email
- 3 tentatives avec backoff sur erreurs 454/timeout/coupure Dovecot
- refuseUser renvoie 503 si un email de refus échoue après retry
- Tests unitaires isTransientSmtpError

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:50:53 +02:00
f71943fa79 fix(front): afficher le statut Refusé pour les parents (#110)
_displayStatus refuse → Refusé ; filtre dropdown Parents aligné.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 22:19:29 +02:00
65f10e9ca6 fix(front): refus famille en un seul appel API (#110)
Le back propage le refus et les mails aux co-parents ; évite la 2e requête en erreur.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 22:36:38 +02:00
9eb62ddd13 feat(#110): refus dossier propagé aux co-parents + mail aligné
- `refuseUser` charge tous les parents du même numéro de dossier, leur
  applique le même token_reprise (7 jours), trace une validation refus
  par compte et envoie un mail de refus à chacun. Le refus devient une
  action dossier : peu importe quel parent ouvre ensuite le lien.
- Échec d'envoi mail loggé sans bloquer les autres destinataires.
- Mail de refus aligné sur le look des autres templates (titre/bouton
  verts, bloc motif gestionnaire en encart, échappement HTML cohérent,
  appUrl normalisée).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 19:44:30 +02:00
b6e83bf9ef fix(front): SnackBar de confirmation après refus dossier (#110)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 18:56:31 +02:00
919148fa75 fix(front): en mode debug sans API_BASE_URL, cibler le back prod (web inclus)
Évite Uri.base.origin sur flutter run -d chrome qui visait le serveur de dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 17:52:54 +02:00
7ba8d51668 feat(front): câbler PATCH refus utilisateur depuis wizards validation (#110)
- UserService.refuseUser → /users/:id/refuser
- AM + famille : envoi motifs, onSuccess + refresh liste
- Famille : refus séquentiel pour chaque parent en_attente
- ValidationRefusForm : état isSubmitting (UX)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 17:38:29 +02:00
43dabd1885 merge(master): doc milestone #127 2026-05-04 22:35:14 +02:00
3fd4812a07 merge(master): doc #127 v0.1.0 2026-05-04 22:33:46 +02:00
e6a087147a merge(develop): #127 mot de passe oublié — flux complet (API, front, e-mail) 2026-04-24 12:53:32 +02:00
ca88710e49 fix(mail): aligner la couleur du template reset-password
Made-with: Cursor
2026-04-24 12:05:06 +02:00
644ba6c9c9 refactor(front): mutualiser les formulaires token password (#127)
Extrait un composant commun PasswordTokenFormScreen pour les pages create/reset password, conserve les comportements métier via wrappers (verify/create vs reset) et active la soumission sur Entrée dans mot de passe oublié.

Made-with: Cursor
2026-04-24 12:03:43 +02:00
15123f691c feat(auth): #127 forgot-password + reset-password API (BDD + mail)
Made-with: Cursor
2026-04-23 18:17:39 +02:00
e51e625d93 feat(front): mot de passe oublié — écrans et API (#127)
- Routes /forgot-password et /reset-password (distinct de /create-password).
- AuthService : POST forgot-password (réponse neutre côté UX) et reset-password.
- Login : lien « Mot de passe oublié ? » vers la demande de réinitialisation.

Made-with: Cursor
2026-04-23 17:02:59 +02:00
10 changed files with 387 additions and 104 deletions

View File

@ -1,7 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { MailService, ValidationAccountEmailKind } from './mail.service'; import { MailService, ValidationAccountEmailKind, isTransientSmtpError } 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,20 +2,125 @@ 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',
@ -81,50 +186,48 @@ 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 { const emailFromName = this.configService.get<string>('email_from_name');
// Récupération de la configuration SMTP const emailFromAddress = this.configService.get<string>('email_from_address');
const smtpHost = this.configService.get<string>('smtp_host'); const mailOptions = {
const smtpPort = this.configService.get<number>('smtp_port'); from: `"${emailFromName}" <${emailFromAddress}>`,
const smtpSecure = this.configService.get<boolean>('smtp_secure'); to,
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required'); subject,
const smtpUser = this.configService.get<string>('smtp_user'); text: text || html.replace(/<[^>]*>?/gm, ''),
const smtpPassword = this.configService.get<string>('smtp_password'); html,
const emailFromName = this.configService.get<string>('email_from_name'); };
const emailFromAddress = this.configService.get<string>('email_from_address');
// Import dynamique de nodemailer let lastError: unknown;
const nodemailer = await import('nodemailer');
// Configuration du transporteur for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) {
const transportConfig: any = { try {
host: smtpHost, const transporter = await this.getTransporter();
port: smtpPort, await transporter.sendMail(mailOptions);
secure: smtpSecure, this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
}; return;
} catch (error) {
lastError = error;
const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error);
if (smtpAuthRequired && smtpUser && smtpPassword) { if (canRetry) {
transportConfig.auth = { const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1);
user: smtpUser, this.logger.warn(
pass: smtpPassword, `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;
} }
const transporter = nodemailer.createTransport(transportConfig);
// Envoi de l'email
await transporter.sendMail({
from: `"${emailFromName}" <${emailFromAddress}>`,
to,
subject,
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
html,
});
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
} catch (error) {
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
throw error;
} }
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
this.resetTransporter();
throw lastError;
} }
/** /**
@ -214,21 +317,29 @@ 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'); const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
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 commentBlock = comment const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.');
? `<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: #333;">Bonjour ${prenom} ${nom},</h2> <h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p> <p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
${commentBlock} <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>
<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: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a> <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>
</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 } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } 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 compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */ /** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. 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,40 +340,79 @@ 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 compte en attente peut être refusé.'); throw new BadRequestException('Seul un dossier 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);
user.statut = StatutUtilisateurType.REFUSE; const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
user.token_reprise = tokenReprise; ? await this.usersRepository.find({
user.token_reprise_expire_le = expireLe; where: {
const savedUser = await this.usersRepository.save(user); role: RoleType.PARENT,
numero_dossier: user.numero_dossier,
},
})
: [user];
const validation = this.validationRepository.create({ const usersARefuser = usersDossier.length ? usersDossier : [user];
user: savedUser, const utilisateurInvalide = usersARefuser.find(
type: 'refus_compte', (u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
status: StatutValidationType.REFUSE, );
validated_by: currentUser, if (utilisateurInvalide) {
comment, throw new BadRequestException(
}); 'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
await this.validationRepository.save(validation);
try {
await this.mailService.sendRefusEmail(
savedUser.email,
savedUser.prenom ?? '',
savedUser.nom ?? '',
comment,
tokenReprise,
); );
} catch (err) {
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
} }
return savedUser; 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,
type: 'refus_compte',
status: StatutValidationType.REFUSE,
validated_by: currentUser,
comment,
});
await manager.save(Validation, validation);
}
return updatedUsers;
});
const emailFailures: string[] = [];
for (const savedUser of savedUsers) {
try {
await this.mailService.sendRefusEmail(
savedUser.email,
savedUser.prenom ?? '',
savedUser.nom ?? '',
comment,
tokenReprise,
);
} catch (err) {
emailFailures.push(savedUser.email);
this.logger.error(`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];
} }
/** /**

View File

@ -1,18 +1,21 @@
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
class Env { class Env {
/// Base URL de lAPI (sans `/api/v1`). /// Base URL de lAPI (sans `/api/v1`).
/// ///
/// - **Web prod** : par défaut on utilise [Uri.base.origin] (même schéma + hôte que la page), /// - **`API_BASE_URL` en `--dart-define`** : prioritaire (back local, staging, etc.).
/// ce qui évite les blocages « mixed content » (ex. page en `http://` vs API en `https://`) /// - **Mode debug sans define** : toujours **`https://app.ptits-pas.fr`** (web inclus) pour
/// et les builds `--dart-define=API_BASE_URL` ne serait pas passé. /// pointer sur le back de prod depuis lIDE / `flutter run`.
/// - **Web dev** : surcharger avec `--dart-define=API_BASE_URL=http://localhost:3000` (ou lURL du back). /// - **Web release / profile sans define** : [Uri.base.origin] (même hôte que la page déployée).
/// - **Mobile** : défaut `https://app.ptits-pas.fr` ou `API_BASE_URL` en `--dart-define`. /// - **Mobile release / profile sans define** : `https://app.ptits-pas.fr`.
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,6 +215,30 @@ 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,6 +104,8 @@ 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,6 +158,13 @@ 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,15 +480,12 @@ 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 (!mounted) return; if (comment == null || comment.trim().isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar( _refuserEnvoyer(comment.trim());
const SnackBar(
content: Text('Refus (à brancher sur lAPI refus)')),
);
widget.onClose();
}, },
), ),
), ),
@ -496,4 +493,34 @@ 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,16 +697,12 @@ 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 (!mounted) return; if (comment == null || comment.trim().isEmpty) return;
ScaffoldMessenger.of(context).showSnackBar( _refuserEnvoyer(comment.trim());
const SnackBar(
content: Text(
'Refus (à brancher sur lAPI refus ticket #110)')),
);
widget.onClose();
}, },
), ),
), ),
@ -714,4 +710,50 @@ 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,12 +8,15 @@ 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
@ -67,6 +70,7 @@ 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,
@ -91,7 +95,7 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
Row( Row(
children: [ children: [
TextButton( TextButton(
onPressed: widget.onCancel, onPressed: widget.isSubmitting ? null : widget.onCancel,
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
const Spacer(), const Spacer(),
@ -99,19 +103,29 @@ class _ValidationRefusFormState extends State<ValidationRefusForm> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton( TextButton(
onPressed: widget.onPrevious, onPressed: widget.isSubmitting ? null : widget.onPrevious,
child: const Text('Précédent'), child: const Text('Précédent'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( if (widget.isSubmitting)
style: ValidationModalTheme.primaryElevatedStyle, const Padding(
onPressed: () { padding: EdgeInsets.symmetric(horizontal: 12),
if (_formKey.currentState!.validate()) { child: SizedBox(
widget.onSubmit(_controller.text.trim()); width: 22,
} height: 22,
}, child: CircularProgressIndicator(strokeWidth: 2),
child: const Text('Envoyer'), ),
), )
else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(_controller.text.trim());
}
},
child: const Text('Envoyer'),
),
], ],
), ),
], ],