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 { 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;
|
||||||
|
|||||||
@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
|
|
||||||
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, '<').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: #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;">
|
||||||
|
|||||||
@ -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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 l’API (sans `/api/v1`).
|
/// Base URL de l’API (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 l’IDE / `flutter run`.
|
/// et les builds où `--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 l’URL 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(() {
|
||||||
|
|||||||
@ -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 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: [
|
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 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).
|
/// 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: () {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user