[#101] [Frontend] Inscription parent — API, soumission et validation
Squash merge de develop vers master. Livrables principaux (ticket #101 et mise au point associée) : - Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent - Payload DTO (parents, enfants, photos base64, CGU) et services Auth - Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées - Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/ Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.). Closes #101 Made-with: Cursor
This commit is contained in:
@@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d
|
||||
# Environnement
|
||||
NODE_ENV=development
|
||||
|
||||
# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU.
|
||||
# Local : laisser vide → ./uploads/photos (relatif au cwd du processus).
|
||||
# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml).
|
||||
# UPLOAD_PHOTOS_DIR=
|
||||
#
|
||||
# Reverse proxy : si Nginx devant l’API, augmenter la taille du corps (ex. client_max_body_size 16m;).
|
||||
# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+.
|
||||
|
||||
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
|
||||
# LOG_API_REQUESTS=true
|
||||
|
||||
+67
-6
@@ -1,26 +1,87 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
|
||||
import { DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
|
||||
import * as path from 'path';
|
||||
import * as express from 'express';
|
||||
|
||||
/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */
|
||||
function setStaticImageCorsHeaders(res: express.Response): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
}
|
||||
|
||||
const staticImageServeOptions = {
|
||||
index: false,
|
||||
fallthrough: true,
|
||||
setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res),
|
||||
};
|
||||
|
||||
/** Préflight si le navigateur interroge OPTIONS sur les médias. */
|
||||
function uploadsCorsPreflight(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): void {
|
||||
if (req.method === 'OPTIONS') {
|
||||
setStaticImageCorsHeaders(res);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */
|
||||
function resolveUploadsFilesystemRoot(): string {
|
||||
const raw = process.env.UPLOAD_PHOTOS_DIR?.trim();
|
||||
const photosDir = raw
|
||||
? path.isAbsolute(raw)
|
||||
? raw
|
||||
: path.resolve(process.cwd(), raw)
|
||||
: path.join(process.cwd(), 'uploads', 'photos');
|
||||
return path.dirname(photosDir);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule,
|
||||
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||
// Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué.
|
||||
app.useBodyParser('json', { limit: '15mb' });
|
||||
app.useBodyParser('urlencoded', { extended: true, limit: '15mb' });
|
||||
|
||||
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
|
||||
// CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes.
|
||||
app.enableCors({
|
||||
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
|
||||
origin: true, // Reflète l’Origin (dev / prod) — routes API + cookies JWT
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const uploadsRoot = resolveUploadsFilesystemRoot();
|
||||
|
||||
// Photos : chemins stockés en base type /uploads/photos/...
|
||||
// En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs).
|
||||
expressApp.use('/uploads', uploadsCorsPreflight);
|
||||
expressApp.use('/api/v1/uploads', uploadsCorsPreflight);
|
||||
|
||||
app.useStaticAssets(uploadsRoot, {
|
||||
prefix: '/uploads/',
|
||||
...staticImageServeOptions,
|
||||
});
|
||||
expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions));
|
||||
|
||||
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
@@ -98,6 +98,44 @@ export class MailService {
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accusé de réception inscription parent : demande enregistrée + n° de dossier.
|
||||
* L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade.
|
||||
*/
|
||||
async sendParentRegistrationPendingEmail(
|
||||
to: string,
|
||||
prenom: string,
|
||||
nom: string,
|
||||
numeroDossier: string,
|
||||
): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||
|
||||
const safe = (s: string) =>
|
||||
(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
|
||||
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
|
||||
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsqu’il aura été traité.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
|
||||
</div>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email de refus de dossier avec lien reprise (token).
|
||||
* Ticket #110 – Refus sans suppression
|
||||
|
||||
@@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AppConfigModule } from 'src/modules/config';
|
||||
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||
forwardRef(() => UserModule),
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
NumeroDossierModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -22,20 +23,26 @@ import { Parents } from 'src/entities/parents.entity';
|
||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { StatutDossierType } from 'src/entities/dossiers.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly numeroDossierService: NumeroDossierService,
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepo: Repository<Parents>,
|
||||
@@ -211,7 +218,16 @@ export class AuthService {
|
||||
const dateExpiration = new Date();
|
||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||
|
||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
let resultat: {
|
||||
parent1: Users;
|
||||
parent2: Users | null;
|
||||
enfants: Children[];
|
||||
tokenCreationMdp: string;
|
||||
tokenCoParent: string | null;
|
||||
};
|
||||
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const parent1 = manager.create(Users, {
|
||||
@@ -321,6 +337,25 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119)
|
||||
const presentationTrim = dto.presentation_dossier?.trim();
|
||||
const dossierFamilleEnt = manager.create(DossierFamille, {
|
||||
numero_dossier: numeroDossier,
|
||||
presentation: presentationTrim || undefined,
|
||||
statut: StatutDossierType.ENVOYE,
|
||||
parent: entiteParent,
|
||||
});
|
||||
const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt);
|
||||
for (const enfantEnregistre of enfantsEnregistres) {
|
||||
await manager.save(
|
||||
DossierFamilleEnfant,
|
||||
manager.create(DossierFamilleEnfant, {
|
||||
id_dossier_famille: dossierFamilleSaved.id,
|
||||
id_enfant: enfantEnregistre.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
parent1: parent1Enregistre,
|
||||
parent2: parent2Enregistre,
|
||||
@@ -329,6 +364,38 @@ export class AuthService {
|
||||
tokenCoParent,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException(
|
||||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
@@ -336,6 +403,7 @@ export class AuthService {
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,7 +472,9 @@ export class AuthService {
|
||||
const dateConsentementPhoto =
|
||||
dto.consentement_photo ? new Date() : undefined;
|
||||
|
||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
let resultat: { user: Users };
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const user = manager.create(Users, {
|
||||
@@ -443,6 +513,12 @@ export class AuthService {
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
@@ -464,7 +540,12 @@ export class AuthService {
|
||||
const extension = correspondances[1];
|
||||
const tamponImage = Buffer.from(correspondances[2], 'base64');
|
||||
|
||||
const dossierUpload = '/app/uploads/photos';
|
||||
const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim();
|
||||
const dossierUpload = rawDir
|
||||
? path.isAbsolute(rawDir)
|
||||
? rawDir
|
||||
: path.resolve(process.cwd(), rawDir)
|
||||
: path.join(process.cwd(), 'uploads', 'photos');
|
||||
await fs.mkdir(dossierUpload, { recursive: true });
|
||||
|
||||
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||||
@@ -575,4 +656,13 @@ export class AuthService {
|
||||
token: user.token_reprise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */
|
||||
private isPostgresUniqueViolation(err: unknown): boolean {
|
||||
if (!(err instanceof QueryFailedError)) {
|
||||
return false;
|
||||
}
|
||||
const code = (err.driverError as { code?: string } | undefined)?.code;
|
||||
return code === '23505';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,18 @@ export class DossierFamilleEnfantDto {
|
||||
due_date?: Date;
|
||||
@ApiProperty({ enum: StatutEnfantType })
|
||||
status: StatutEnfantType;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)',
|
||||
})
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||
})
|
||||
consent_photo?: boolean;
|
||||
}
|
||||
|
||||
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -209,6 +210,20 @@ export class ParentsService {
|
||||
}
|
||||
|
||||
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||
private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto {
|
||||
return {
|
||||
id: child.id,
|
||||
first_name: child.first_name,
|
||||
last_name: child.last_name,
|
||||
genre: child.gender,
|
||||
birth_date: child.birth_date,
|
||||
due_date: child.due_date,
|
||||
status: child.status,
|
||||
photo_url: child.photo_url ?? undefined,
|
||||
consent_photo: child.consent_photo,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeParentIds(parentIds: unknown): string[] {
|
||||
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||
if (typeof parentIds === 'string') {
|
||||
@@ -257,15 +272,7 @@ export class ParentsService {
|
||||
if (p.parentChildren) {
|
||||
for (const pc of p.parentChildren) {
|
||||
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||
enfantsMap.set(pc.child.id, {
|
||||
id: pc.child.id,
|
||||
first_name: pc.child.first_name,
|
||||
last_name: pc.child.last_name,
|
||||
genre: pc.child.gender,
|
||||
birth_date: pc.child.birth_date,
|
||||
due_date: pc.child.due_date,
|
||||
status: pc.child.status,
|
||||
});
|
||||
enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +282,16 @@ export class ParentsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif)
|
||||
if (dossierFamille?.enfants?.length) {
|
||||
for (const dfe of dossierFamille.enfants) {
|
||||
const c = dfe.enfant;
|
||||
if (c && !enfantsMap.has(c.id)) {
|
||||
enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||
user_id: p.user_id,
|
||||
email: p.user.email,
|
||||
|
||||
Reference in New Issue
Block a user