Compare commits
7 Commits
43dabd1885
...
e1684676a7
| Author | SHA1 | Date | |
|---|---|---|---|
| e1684676a7 | |||
| f71943fa79 | |||
| 65f10e9ca6 | |||
| 9eb62ddd13 | |||
| b6e83bf9ef | |||
| 919148fa75 | |||
| 7ba8d51668 |
@ -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;
|
||||||
|
|||||||
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
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, '<').replace(/>/g, '>')}</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;">
|
||||||
|
|||||||
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 l’API (sans `/api/v1`).
|
/// Base URL de l’API (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 où `--dart-define=API_BASE_URL` ne serait pas passé.
|
/// pointer sur le back de prod depuis l’IDE / `flutter run`.
|
||||||
/// - **Web dev** : surcharger avec `--dart-define=API_BASE_URL=http://localhost:3000` (ou l’URL 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(() {
|
||||||
|
|||||||
@ -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 l’API 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 l’API 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user