import fetch from 'node-fetch'; import { v4 as uuidv4 } from 'uuid'; import { IdNotService } from '../services/idnot'; import { authTokens } from '../utils/auth-tokens'; import { IdNotUser, AuthToken } from '../types'; import { Logger } from '../utils/logger'; import { NotFoundError, ExternalServiceError, BusinessRuleError, UnauthorizedError, ForbiddenError, ValidationError } from '../types/errors'; /** * Pure controller methods that handle business logic * without depending on Express Request/Response objects */ export class IdNotController { /** * Get user rattachements by idNot */ static async getUserRattachements(idNot: string): Promise { Logger.info('Getting user rattachements', { idNot }); const json = await IdNotService.getUserRattachements(idNot); // Check if any rattachements found if (!json.result || json.result.length === 0) { throw new NotFoundError('No rattachements found'); } // Get office data for each rattachement const officeData = await Promise.all(json.result.map(async (result: any) => { const searchParams = new URLSearchParams({ key: process.env.IDNOT_API_KEY || '', deleted: 'false' }); try { const response = await fetch(`${process.env.IDNOT_ANNUARY_BASE_URL}${result.entiteUrl}?` + searchParams, { method: 'GET' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { Logger.error('Failed to fetch office data', { entiteUrl: result.entiteUrl, error: error instanceof Error ? error.message : 'Unknown error' }); throw new ExternalServiceError('IdNot', `Failed to fetch office data: ${error instanceof Error ? error.message : 'Unknown error'}`); } })); Logger.info('Successfully retrieved user rattachements', { idNot, count: officeData.length }); return officeData; } /** * Get office rattachements by office idNot */ static async getOfficeRattachements(idNot: string): Promise { Logger.info('Getting office rattachements', { idNot }); try { const result = await IdNotService.getOfficeRattachements(idNot); Logger.info('Successfully retrieved office rattachements', { idNot, count: result.result?.length || 0 }); return result; } catch (error) { Logger.error('Failed to get office rattachements', { idNot, error: error instanceof Error ? error.message : 'Unknown error' }); throw new ExternalServiceError('IdNot', `Failed to get office rattachements: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Authenticate user with authorization code */ static async authenticate(code: string): Promise<{ idNotUser: IdNotUser; authToken: string }> { Logger.info('IdNot authentication initiated', { codePrefix: code.substring(0, 8) + '...' }); try { // Exchange code for tokens const tokens = await IdNotService.exchangeCodeForTokens(code); const jwt = tokens.id_token; if (!jwt) { throw new BusinessRuleError('No ID token received from IdNot'); } // Decode JWT payload const payload = JSON.parse(Buffer.from(jwt.split('.') [1], 'base64').toString('utf8')); // Log non-sensible claims for diagnostics Logger.info('IdNot token payload summary', { keys: Object.keys(payload || {}), sub: payload?.sub, entity_idn: payload?.entity_idn, profile_idn: payload?.profile_idn }); // Always use sub for rattachements API as it's more reliable than profile_idn Logger.info('IdNot using rattachements API with sub', { sub: payload.sub }); const rattachementsJson = await IdNotService.getUserRattachements(payload.sub); const results: any[] = Array.isArray(rattachementsJson?.result) ? rattachementsJson.result : []; if (results.length === 0) { throw new ForbiddenError('User not attached to an office'); } // Get the first rattachement const rattachement = results[0]; // Fetch entite and personne data separately if not present let entiteData = rattachement.entite; let personneData = rattachement.personne; if (!entiteData && rattachement.entiteUrl) { Logger.info('IdNot fetching entite data', { entiteUrl: rattachement.entiteUrl }); entiteData = await IdNotService.getEntiteData(rattachement.entiteUrl); } if (!personneData && rattachement.personneUrl) { Logger.info('IdNot fetching personne data', { personneUrl: rattachement.personneUrl }); personneData = await IdNotService.getPersonneData(rattachement.personneUrl); } const userData = { ...rattachement, entite: entiteData, personne: personneData }; // Log d'analyse (non sensible) pour diagnostiquer les cas de rattachement Logger.info('IdNot userData summary', { profile_idn: payload.profile_idn, entity_idn: payload.entity_idn, entiteTypeName: userData?.entite?.typeEntite?.name, entiteCodeCrpcen: userData?.entite?.codeCrpcen, statutDuRattachement: userData?.statutDuRattachement, typeLien: userData?.typeLien?.name }); if (!userData || !userData.statutDuRattachement || userData.entite.typeEntite.name !== 'office') { throw new ForbiddenError('User not attached to an office'); } // Get office location data const officeLocationData = await IdNotService.getOfficeLocationData(userData.entite.locationsUrl); if (!officeLocationData || !officeLocationData.result || officeLocationData.result.length === 0) { throw new BusinessRuleError('Office location data not found'); } // Build IdNotUser object const idNotUser: IdNotUser = { idNot: payload.sub, office: { idNot: payload.entity_idn, name: userData.entite.denominationSociale ?? userData.entite.codeCrpcen, crpcen: userData.entite.codeCrpcen, office_status: IdNotService.getOfficeStatus(userData.entite.statutEntite.name), address: { address: officeLocationData.result[0].adrGeo4, city: officeLocationData.result[0].adrGeoVille.split(' ')[0] ?? officeLocationData.result[0].adrGeoVille, zip_code: Number(officeLocationData.result[0].adrGeoCodePostal) }, status: 'ACTIVE' }, role: IdNotService.getRole(userData.typeLien.name), contact: { first_name: userData.personne.prenom, last_name: userData.personne.nomUsuel, email: userData.mailRattachement, phone_number: userData.numeroTelephone, cell_phone_number: userData.numeroMobile ?? userData.numeroTelephone, civility: IdNotService.getCivility(userData.personne.civilite) }, office_role: IdNotService.getOfficeRole(userData.typeLien.name) }; if (!idNotUser.contact.email) { throw new UnauthorizedError('Email not found'); } // Generate auth token const authToken = uuidv4(); const tokenData: AuthToken = { idNot: idNotUser.idNot, authToken, idNotUser: idNotUser, pairingId: null, defaultStorage: null, createdAt: Date.now(), expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours }; authTokens.push(tokenData); Logger.info('IdNot authentication successful', { idNot: idNotUser.idNot, office: idNotUser.office.name }); return { idNotUser, authToken }; } catch (error) { Logger.error('IdNot authentication failed', { codePrefix: code.substring(0, 8) + '...', error: error instanceof Error ? error.message : 'Unknown error' }); // Laisser passer les erreurs applicatives connues (4xx) pour éviter un 502 côté client // Approche robuste: on tolère les divergences d'instance en vérifiant le statusCode et le nom const maybeStatus = (error as any)?.statusCode; const maybeName = (error as any)?.name as string | undefined; // 1) Si une erreur possède un statusCode 4xx, on la relaisse passer if (typeof maybeStatus === 'number' && maybeStatus >= 400 && maybeStatus < 500) { throw error; } // 2) Si le nom correspond à une erreur applicative connue, on la relaisse passer if ( error instanceof BusinessRuleError || error instanceof ForbiddenError || error instanceof UnauthorizedError || error instanceof ValidationError || maybeName === 'BusinessRuleError' || maybeName === 'ForbiddenError' || maybeName === 'UnauthorizedError' || maybeName === 'ValidationError' ) { throw error; } throw new ExternalServiceError('IdNot', `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get current user data by idNot and authToken */ static async getCurrentUser(idNot: string, authToken: string): Promise<{ success: boolean; data: IdNotUser }> { Logger.info('Getting current user data', { idNot }); // Find the full token data const userAuth = authTokens.find(auth => auth.authToken === authToken); if (!userAuth || !userAuth.idNotUser) { throw new NotFoundError('User data not found. Please log in again.'); } Logger.info('Current user data retrieved', { idNot, office: userAuth.idNotUser.office.name }); return { success: true, data: userAuth.idNotUser }; } /** * Logout user by removing auth token */ static async logout(authToken: string): Promise<{ success: boolean; message: string }> { Logger.info('User logout initiated'); // Remove the auth token from the array const tokenIndex = authTokens.findIndex(auth => auth.authToken === authToken); if (tokenIndex > -1) { const removedToken = authTokens.splice(tokenIndex, 1)[0]; Logger.info('User logout successful', { idNot: removedToken.idNot }); } else { Logger.warn('Logout attempted with invalid token'); } return { success: true, message: 'Déconnexion réussie' }; } /** * Validate if a token is still valid */ static async validateToken(idNot: string): Promise<{ success: boolean; message: string; data: { idNot: string; valid: boolean } }> { Logger.debug('Token validation requested', { idNot }); return { success: true, message: 'Token valide', data: { idNot, valid: true } }; } }