diff --git a/backend/src/common/utils/nir.util.ts b/backend/src/common/utils/nir.util.ts new file mode 100644 index 0000000..30ecb87 --- /dev/null +++ b/backend/src/common/utils/nir.util.ts @@ -0,0 +1,109 @@ +/** + * Utilitaire de validation du NIR (numéro de sécurité sociale français). + * - Format 15 caractères (chiffres ou 2A/2B pour la Corse). + * - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A→19, 2B→20). + * - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet. + */ + +const NIR_CORSE_2A = '19'; +const NIR_CORSE_2B = '20'; + +/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */ +const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i; + +/** + * Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A→19, 2B→20). + */ +export function nirTo13Digits(nir: string): string { + const n = nir.toUpperCase().replace(/\s/g, ''); + if (n.length !== 15) return ''; + const dept = n.slice(5, 7); + let deptNum: string; + if (dept === '2A') deptNum = NIR_CORSE_2A; + else if (dept === '2B') deptNum = NIR_CORSE_2B; + else deptNum = dept; + return n.slice(0, 5) + deptNum + n.slice(7, 13); +} + +/** + * Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés). + */ +export function isNirFormatValid(nir: string): boolean { + if (!nir || typeof nir !== 'string') return false; + const n = nir.replace(/\s/g, '').toUpperCase(); + return NIR_FORMAT.test(n); +} + +/** + * Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)). + * Retourne un nombre entre 1 et 97. + */ +export function computeNirKey(nir13: string): number { + const num = parseInt(nir13, 10); + if (Number.isNaN(num) || nir13.length !== 13) return -1; + return 97 - (num % 97); +} + +/** + * Vérifie la clé de contrôle du NIR (15 caractères). + * Retourne true si le NIR est valide (format + clé). + */ +export function isNirKeyValid(nir: string): boolean { + const n = nir.replace(/\s/g, '').toUpperCase(); + if (n.length !== 15) return false; + const nir13 = nirTo13Digits(n); + if (nir13.length !== 13) return false; + const expectedKey = computeNirKey(nir13); + const actualKey = parseInt(n.slice(13, 15), 10); + return expectedKey === actualKey; +} + +export interface NirValidationResult { + valid: boolean; + error?: string; + warning?: string; +} + +/** + * Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider. + */ +export function validateNir( + nir: string, + options?: { dateNaissance?: string; genre?: 'H' | 'F' }, +): NirValidationResult { + const n = (nir || '').replace(/\s/g, '').toUpperCase(); + if (n.length === 0) return { valid: false, error: 'Le NIR est requis' }; + if (!isNirFormatValid(n)) { + return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' }; + } + if (!isNirKeyValid(n)) { + return { valid: false, error: 'Clé de contrôle du NIR invalide' }; + } + let warning: string | undefined; + if (options?.genre) { + const sexNir = n[0]; + const expectedSex = options.genre === 'F' ? '2' : '1'; + if (sexNir !== expectedSex) { + warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).'; + } + } + if (options?.dateNaissance) { + try { + const d = new Date(options.dateNaissance); + if (!Number.isNaN(d.getTime())) { + const year2 = d.getFullYear() % 100; + const month = d.getMonth() + 1; + const nirYear = parseInt(n.slice(1, 3), 10); + const nirMonth = parseInt(n.slice(3, 5), 10); + if (nirYear !== year2 || nirMonth !== month) { + warning = warning + ? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).` + : 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).'; + } + } + } catch { + // ignore + } + } + return { valid: true, warning }; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1fdb8cd..76f2ddd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -23,6 +23,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; +import { validateNir } from 'src/common/utils/nir.util'; @Injectable() export class AuthService { @@ -325,6 +326,18 @@ export class AuthService { ); } + const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance, + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + if (nirValidation.warning) { + // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) + console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email); + } + const existe = await this.usersService.findByEmailOrNull(dto.email); if (existe) { throw new ConflictException('Un compte avec cet email existe déjà'); @@ -370,7 +383,7 @@ export class AuthService { const am = amRepo.create({ user_id: userEnregistre.id, approval_number: dto.numero_agrement, - nir: dto.nir, + nir: nirNormalized, max_children: dto.capacite_accueil, biography: dto.biographie, residence_city: dto.ville ?? undefined,