diff --git a/src/config/email.ts b/src/config/email.ts new file mode 100644 index 0000000..b93c854 --- /dev/null +++ b/src/config/email.ts @@ -0,0 +1,8 @@ +export const emailConfig = { + MAILCHIMP_API_KEY: process.env.MAILCHIMP_API_KEY, + MAILCHIMP_KEY: process.env.MAILCHIMP_KEY, + MAILCHIMP_LIST_ID: process.env.MAILCHIMP_LIST_ID, + PORT: parseInt(process.env.PORT || '8080'), + FROM_EMAIL: 'no-reply@lecoffre.io', + FROM_NAME: 'LeCoffre.io' +}; diff --git a/src/config/idnot.ts b/src/config/idnot.ts new file mode 100644 index 0000000..e74f37b --- /dev/null +++ b/src/config/idnot.ts @@ -0,0 +1,13 @@ +export const idnotConfig = { + IDNOT_API_KEY: process.env.IDNOT_API_KEY, + IDNOT_ANNUARY_BASE_URL: process.env.IDNOT_ANNUARY_BASE_URL, + + // OAuth2 credentials (these should ideally be moved to env vars for security) + CLIENT_ID: 'B3CE56353EDB15A9', + CLIENT_SECRET: '3F733549E879878344B6C949B366BB5CDBB2DB5B7F7AB7EBBEBB0F0DD0776D1C', + REDIRECT_URI: 'http://local.lecoffreio.4nkweb:3000/authorized-client', + + // API URLs + TOKEN_URL: 'https://qual-connexion.idnot.fr/user/IdPOAuth2/token/idnot_idp_v1', + API_BASE_URL: 'https://qual-api.notaires.fr/annuaire' +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..214b871 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,16 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const config = { + port: process.env.PORT || 8080, + defaultStorage: process.env.DEFAULT_STORAGE || 'https://dev3.4nkweb.com/storage', + appHost: process.env.APP_HOST || 'http://localhost:3000', + + cors: { + origin: 'http://localhost:3000', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'x-session-id', 'Authorization'], + credentials: true + } +}; diff --git a/src/config/signer.ts b/src/config/signer.ts new file mode 100644 index 0000000..82b9298 --- /dev/null +++ b/src/config/signer.ts @@ -0,0 +1,9 @@ +import { ClientConfig } from 'sdk-signer-client'; + +export const signerConfig: ClientConfig = { + url: process.env.SIGNER_WS_URL || 'ws://localhost:9090', + apiKey: process.env.SIGNER_API_KEY || 'your-api-key-change-this', + timeout: 30000, + reconnectInterval: 2000, // Let SDK try quick reconnects first + maxReconnectAttempts: 3 // Limited SDK attempts, then our service takes over +}; diff --git a/src/config/sms.ts b/src/config/sms.ts new file mode 100644 index 0000000..d679d65 --- /dev/null +++ b/src/config/sms.ts @@ -0,0 +1,12 @@ +export const smsConfig = { + // OVH config + OVH_APP_KEY: process.env.OVH_APP_KEY, + OVH_APP_SECRET: process.env.OVH_APP_SECRET, + OVH_CONSUMER_KEY: process.env.OVH_CONSUMER_KEY, + OVH_SMS_SERVICE_NAME: process.env.OVH_SMS_SERVICE_NAME, + + // SMS Factor config + SMS_FACTOR_TOKEN: process.env.SMS_FACTOR_TOKEN, + + PORT: parseInt(process.env.PORT || '8080') +}; diff --git a/src/config/stripe.ts b/src/config/stripe.ts new file mode 100644 index 0000000..267b1da --- /dev/null +++ b/src/config/stripe.ts @@ -0,0 +1,5 @@ +export const stripeConfig = { + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + APP_HOST: process.env.APP_HOST || 'http://localhost:3000', +}; diff --git a/src/controllers/email.controller.ts b/src/controllers/email.controller.ts new file mode 100644 index 0000000..84d7dae --- /dev/null +++ b/src/controllers/email.controller.ts @@ -0,0 +1,105 @@ +import { Request, Response } from 'express'; +import { EmailService, pendingEmails } from '../services/email'; +import { ETemplates } from '../types'; + +export class EmailController { + static async sendEmail(req: Request, res: Response) { + const { email, firstName, lastName, officeName, template } = req.body; + + try { + const templateVariables = { + first_name: firstName || '', + last_name: lastName || '', + office_name: officeName || '', + link: `${process.env.APP_HOST}` + }; + + const result = await EmailService.sendTransactionalEmail( + email, + ETemplates[template as keyof typeof ETemplates], + 'Votre notaire vous envoie un message', + templateVariables + ); + + if (!result.success) { + // Add to pending emails to retry later + const emailId = `${email}-${Date.now()}`; + pendingEmails.set(emailId, { + to: email, + templateName: ETemplates[template as keyof typeof ETemplates], + subject: 'Votre notaire vous envoie un message', + templateVariables, + attempts: 1, + lastAttempt: Date.now() + }); + } + + res.json({ + success: true, + message: 'Email envoyé avec succès' + }); + } catch (error: any) { + console.error('Erreur:', error); + res.status(500).json({ + success: false, + message: 'Erreur serveur lors de l\'envoi de l\'email' + }); + } + } + + static async subscribeToList(req: Request, res: Response) { + const { email } = req.body; + + try { + const result = await EmailService.addToMailchimpList(email); + + if (result.success) { + res.json({ + success: true, + message: 'Inscription à la liste réussie' + }); + } else { + res.status(500).json({ + success: false, + message: 'Échec de l\'inscription à la liste' + }); + } + } catch (error: any) { + console.error('Erreur:', error); + res.status(500).json({ + success: false, + message: 'Erreur serveur lors de l\'inscription' + }); + } + } + + static async sendReminder(req: Request, res: Response) { + const { office, customer } = req.body; + + try { + const to = customer.contact.email; + + const templateVariables = { + office_name: office.name, + last_name: customer.contact.last_name || '', + first_name: customer.contact.first_name || '', + link: `${process.env.APP_HOST}` + }; + + await EmailService.sendTransactionalEmail( + to, + ETemplates.DOCUMENT_REMINDER, + 'Vous avez des documents à déposer pour votre dossier.', + templateVariables + ); + + res.json({ + success: true, + message: 'Email envoyé avec succès' + }); + } catch (error) { + console.error(error); + return; + } + } +} diff --git a/src/controllers/health.controller.ts b/src/controllers/health.controller.ts new file mode 100644 index 0000000..68c1168 --- /dev/null +++ b/src/controllers/health.controller.ts @@ -0,0 +1,7 @@ +import { Request, Response } from 'express'; + +export class HealthController { + static getHealth(req: Request, res: Response) { + res.json({ message: 'OK' }); + } +} diff --git a/src/controllers/idnot.controller.ts b/src/controllers/idnot.controller.ts new file mode 100644 index 0000000..6fedd9f --- /dev/null +++ b/src/controllers/idnot.controller.ts @@ -0,0 +1,199 @@ +import { Request, Response } from 'express'; +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'; + +export class IdNotController { + static async getUserRattachements(req: Request, res: Response): Promise { + const { idNot } = req.query; + + const json = await IdNotService.getUserRattachements(idNot as string); + + // if json.result.length is 0, return 404 + if (json.result.length === 0) { + return res.status(404).json({ + success: false, + message: 'No rattachements found' + }); + } + + // Iterate over all results and get the office data by calling the entiteUrl endpoint + const officeData = await Promise.all(json.result.map(async (result: any) => { + const searchParams = new URLSearchParams({ + key: process.env.IDNOT_API_KEY || '', + deleted: 'false' + }); + + const officeData = await ( + await fetch(`${process.env.IDNOT_ANNUARY_BASE_URL}${result.entiteUrl}?` + searchParams, { + method: 'GET' + }) + ).json(); + return officeData; + })); + + res.json(officeData); + } + + static async getOfficeRattachements(req: Request, res: Response) { + const { idNot } = req.query; + + const json = await IdNotService.getOfficeRattachements(idNot as string); + + res.json(json); + } + + static async authenticate(req: Request, res: Response): Promise { + const code = req.params.code; + + try { + const tokens = await IdNotService.exchangeCodeForTokens(code); + + const jwt = tokens.id_token; + if (!jwt) { + console.error('jwt not defined'); + return; + } + const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64').toString('utf8')); + + let userData: any; + try { + userData = await IdNotService.getUserData(payload.profile_idn); + } catch (error) { + console.error('Error fetching user data:', error); + return; + } + + if (!userData || !userData.statutDuRattachement || userData.entite.typeEntite.name !== 'office') { + console.error('User not attached to an office (May be a partner)'); + return; + } + + let officeLocationData: any; + try { + officeLocationData = await IdNotService.getOfficeLocationData(userData.entite.locationsUrl); + } catch (error) { + console.error('Error fetching office location data:', error); + return; + } + + if (!officeLocationData || !officeLocationData.result || officeLocationData.result.length === 0) { + console.error('Office location data not found'); + return; + } + + 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) { + console.error('User pro email empty'); + return; + } + + const authToken = uuidv4(); + const tokenData: AuthToken = { + idNot: idNotUser.idNot, + authToken, + idNotUser: idNotUser, // Store the full user data + pairingId: null, // To be set on a separate call + defaultStorage: null, // To be set on a separate call + createdAt: Date.now(), + expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours + }; + authTokens.push(tokenData); + + res.json({ idNotUser, authToken }); + } catch (error: any) { + res.status(500).json({ + error: 'Internal Server Error', + message: error.message + }); + } + } + + static async getCurrentUser(req: Request, res: Response): Promise { + console.log('Received request to get user data'); + try { + // Find the full token data which should contain the original idNotUser data + const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); + + if (!userAuth || !userAuth.idNotUser) { + // If we don't have the stored user data, we need to re-fetch it + // This requires decoding the original JWT or re-fetching from IdNot APIs + return res.status(404).json({ + success: false, + message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' + }); + } + + // Return the stored idNotUser data without the authToken + res.json({ + success: true, + data: userAuth.idNotUser + }); + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la récupération des données utilisateur', + error: error.message + }); + } + } + + static logout(req: Request, res: Response) { + try { + // Remove the auth token from the array + const tokenIndex = authTokens.findIndex(auth => auth.authToken === req.idNotUser!.authToken); + if (tokenIndex > -1) { + authTokens.splice(tokenIndex, 1); + } + + res.json({ + success: true, + message: 'Déconnexion réussie' + }); + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la déconnexion', + error: error.message + }); + } + } + + static validateToken(req: Request, res: Response) { + res.json({ + success: true, + message: 'Token valide', + data: { + idNot: req.idNotUser!.idNot, + valid: true + } + }); + } +} diff --git a/src/controllers/process-improved.controller.ts b/src/controllers/process-improved.controller.ts new file mode 100644 index 0000000..aa6e25e --- /dev/null +++ b/src/controllers/process-improved.controller.ts @@ -0,0 +1,449 @@ +import { Request, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { Database } from '../database'; +import { SignerImprovedService } from '../services/signer-improved'; +import { SessionManager } from '../utils/session-manager'; +import { authTokens } from '../utils/auth-tokens'; +import { ProcessInfo, ProcessData, ProcessRoles, EOfficeStatus } from '../types'; +import { config } from '../config'; +import { Logger } from '../utils/logger'; +import { + AppError, + ErrorCode, + NotFoundError, + ExternalServiceError, + BusinessRuleError +} from '../types/errors'; +import { asyncHandler } from '../middleware/error-handler'; + +export class ProcessImprovedController { + static getUserProcess = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + + Logger.info('User process request initiated', { requestId }); + + // Find the full token data which should contain the original idNotUser data + const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); + + if (!userAuth || !userAuth.idNotUser) { + throw new NotFoundError('Données utilisateur non trouvées. Veuillez vous reconnecter.', requestId); + } + + const { pairingId } = req.query; + + // Execute signer operations with retry logic + const processResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot); + }, + 'getUserProcessByIdnot', + 3 + ); + + if (!processResult.success) { + throw new ExternalServiceError('Signer', processResult.error?.message || 'Failed to get user process', requestId); + } + + let process: ProcessInfo | null = processResult.data || null; + + if (!process) { + Logger.info('No existing process found, creating new one', { + requestId, + userIdNot: userAuth.idNotUser.idNot + }); + + // Get UUID from database + let uuid: string; + try { + const result = await Database.query('SELECT uid FROM users WHERE "idNot" = $1', [userAuth.idNotUser.idNot]); + uuid = result.rows.length > 0 ? result.rows[0].uid : null; + } catch (error) { + Logger.error('Error fetching UUID by idNot', { + requestId, + error: error instanceof Error ? error.message : 'Unknown error' + }); + uuid = ''; + } + + if (!uuid) { + Logger.info('No existing UUID found in db, generating new one', { requestId }); + uuid = uuidv4(); + } + + const processData: ProcessData = { + uid: uuid, + utype: 'collaborator', + idNot: userAuth.idNotUser.idNot, + office: { + idNot: userAuth.idNotUser.office.idNot, + }, + role: userAuth.idNotUser.role, + office_role: userAuth.idNotUser.office_role, + contact: userAuth.idNotUser.contact, + }; + + const privateFields = Object.keys(processData); + const allFields = [...privateFields, 'roles']; + // Make those fields public + privateFields.splice(privateFields.indexOf('uid'), 1); + privateFields.splice(privateFields.indexOf('utype'), 1); + privateFields.splice(privateFields.indexOf('idNot'), 1); + + // Get pairing ID with retry + const pairingResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.getPairingId(); + }, + 'getPairingId', + 3 + ); + + if (!pairingResult.success) { + throw new ExternalServiceError('Signer', pairingResult.error?.message || 'Failed to get pairing ID', requestId); + } + + const validatorId = pairingResult.data!.pairingId; + + const roles: ProcessRoles = { + owner: { + members: [pairingId as string, validatorId], + validation_rules: [ + { + quorum: 0.1, + fields: allFields, + min_sig_member: 1, + } + ], + storages: [config.defaultStorage] + }, + apophis: { + members: [pairingId as string, validatorId], + validation_rules: [], + storages: [] + } + }; + + // Create process with retry + const createResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.createProcess(processData, privateFields, roles); + }, + 'createProcess', + 3 + ); + + if (!createResult.success) { + throw new ExternalServiceError('Signer', createResult.error?.message || 'Failed to create process', requestId); + } + + Logger.info('Created new process', { + requestId, + processId: createResult.data!.processId + }); + + process = { + processId: createResult.data!.processId || '', + processData: createResult.data!.data + }; + } else { + Logger.info('Using existing process', { + requestId, + processId: process.processId + }); + } + + // Check if process is committed and handle role updates + const processManagementResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + const allProcesses = await signerClient.getOwnedProcesses(); + + if (allProcesses && process) { + const processStates = allProcesses.processes[process.processId].states; + const isNotCommited = processStates.length === 2 + && processStates[1].commited_in === processStates[0].commited_in; + + if (isNotCommited) { + Logger.info('Process not committed, committing it', { + requestId, + processId: process.processId + }); + await signerClient.validateState(process.processId, processStates[0].state_id); + } + + // Check pairing ID in roles + let roles: ProcessRoles; + if (isNotCommited) { + const firstState = processStates[0]; + roles = firstState.roles; + } else { + const tip = processStates[processStates.length - 1].commited_in; + const lastState = processStates.findLast((state: any) => state.commited_in !== tip); + roles = lastState.roles; + } + + if (!roles) { + throw new Error('No roles found'); + } else if (!roles['owner']) { + throw new Error('No owner role found'); + } + + if (!roles['owner'].members.includes(req.query.pairingId as string)) { + Logger.info('Adding new pairingId to owner role', { + requestId, + pairingId: req.query.pairingId, + processId: process.processId + }); + + roles['owner'].members.push(req.query.pairingId as string); + const updatedProcessReturn = await signerClient.updateProcess(process.processId, {}, [], roles); + const processId = updatedProcessReturn.updatedProcess.process_id; + const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; + await signerClient.notifyUpdate(processId, stateId); + await signerClient.validateState(processId, stateId); + } + } + + return process; + }, + 'processManagement', + 2 + ); + + if (!processManagementResult.success) { + throw new ExternalServiceError('Signer', processManagementResult.error?.message || 'Failed to manage process', requestId); + } + + Logger.info('User process request completed successfully', { + requestId, + processId: process?.processId + }); + + res.json({ + success: true, + data: processManagementResult.data + }); + }); + + static getOfficeProcess = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + + Logger.info('Office process request initiated', { requestId }); + + const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); + + if (!userAuth || !userAuth.idNotUser) { + throw new NotFoundError('Données utilisateur non trouvées. Veuillez vous reconnecter.', requestId); + } + + // Check office status + if (userAuth.idNotUser.office.office_status !== EOfficeStatus.ACTIVATED) { + throw new BusinessRuleError('Office not activated', undefined, requestId); + } + + // Get office process with retry + const processResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot); + }, + 'getOfficeProcessByIdnot', + 3 + ); + + if (!processResult.success) { + throw new ExternalServiceError('Signer', processResult.error?.message || 'Failed to get office process', requestId); + } + + let process: ProcessInfo | null = processResult.data || null; + + if (!process) { + Logger.info('No existing office process found, creating new one', { + requestId, + officeIdNot: userAuth.idNotUser.office.idNot + }); + + // Get validator ID with retry + const pairingResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.getPairingId(); + }, + 'getPairingId', + 3 + ); + + if (!pairingResult.success) { + throw new ExternalServiceError('Signer', pairingResult.error?.message || 'Failed to get validator ID', requestId); + } + + const validatorId = pairingResult.data!.pairingId; + if (!validatorId) { + throw new BusinessRuleError('No validator id found', undefined, requestId); + } + + // Get UUID from database + let uuid: string; + try { + const result = await Database.query('SELECT uid FROM offices WHERE "idNot" = $1', [userAuth.idNotUser.office.idNot]); + uuid = result.rows.length > 0 ? result.rows[0].uid : null; + } catch (error) { + Logger.error('Error fetching office UUID by idNot', { + requestId, + error: error instanceof Error ? error.message : 'Unknown error' + }); + uuid = ''; + } + + if (!uuid) { + Logger.info('No existing office UUID found in db, generating new one', { requestId }); + uuid = uuidv4(); + } + + const processData: ProcessData = { + uid: uuid, + utype: 'office', + ...userAuth.idNotUser.office, + }; + + const privateFields = Object.keys(processData); + + const roles: ProcessRoles = { + owner: { + members: [validatorId], + validation_rules: [], + storages: [] + }, + apophis: { + members: [validatorId], + validation_rules: [], + storages: [] + } + }; + + // Create office process with retry + const createResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.createProcess(processData, privateFields, roles); + }, + 'createOfficeProcess', + 3 + ); + + if (!createResult.success) { + throw new ExternalServiceError('Signer', createResult.error?.message || 'Failed to create office process', requestId); + } + + Logger.info('Created new office process', { + requestId, + processId: createResult.data!.processId + }); + + process = { + processId: createResult.data!.processId || '', + processData: createResult.data!.data + }; + } + + Logger.info('Office process request completed successfully', { + requestId, + processId: process?.processId + }); + + res.json({ + success: true, + data: process + }); + }); + + static authenticateClient = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + const { pairingId } = req.body; + + if (!pairingId) { + throw new BusinessRuleError('Missing pairingId', undefined, requestId); + } + + Logger.info('Client authentication initiated', { requestId, pairingId }); + + // This should be implemented properly based on your business logic + // For now, just clean up the session + SessionManager.deleteSession(req.session!.id); + + Logger.info('Client authentication completed', { requestId }); + + res.json({ + success: true, + message: 'Client authentication successful', + data: {} + }); + }); + + static getPhoneNumberForEmail = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + const { email } = req.body; + + if (!email) { + throw new BusinessRuleError('Missing email', undefined, requestId); + } + + Logger.info('Phone number lookup initiated', { requestId, email }); + + const phoneResult = await SignerImprovedService.executeWithRetry( + async (signerClient) => { + return await signerClient.getPhoneNumberForEmail(email); + }, + 'getPhoneNumberForEmail', + 3 + ); + + if (!phoneResult.success) { + throw new ExternalServiceError('Signer', phoneResult.error?.message || 'Failed to get phone number', requestId); + } + + const phoneNumber = phoneResult.data; + + if (!phoneNumber) { + throw new NotFoundError('No phone number found for this email', requestId); + } + + Logger.info('Phone number lookup completed', { requestId, email }); + + res.json({ + success: true, + message: 'Phone number retrieved successfully', + phoneNumber: phoneNumber + }); + }); + + // Health check endpoint for signer service + static getSignerHealth = asyncHandler(async (req: Request, res: Response): Promise => { + const healthStatus = SignerImprovedService.getHealthStatus(); + + res.json({ + success: true, + data: { + signer: healthStatus, + timestamp: new Date().toISOString() + } + }); + }); + + // Force reconnection endpoint (for debugging/admin use) + static forceSignerReconnect = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + + Logger.info('Force signer reconnection requested', { requestId }); + + const reconnectResult = await SignerImprovedService.forceReconnect(); + + if (!reconnectResult.success) { + throw new ExternalServiceError('Signer', reconnectResult.error?.message || 'Failed to reconnect', requestId); + } + + Logger.info('Force signer reconnection completed', { requestId }); + + res.json({ + success: true, + message: 'Signer reconnection initiated', + data: SignerImprovedService.getHealthStatus() + }); + }); +} diff --git a/src/controllers/process.controller.ts b/src/controllers/process.controller.ts new file mode 100644 index 0000000..ee6490b --- /dev/null +++ b/src/controllers/process.controller.ts @@ -0,0 +1,326 @@ +import { Request, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { Database } from '../database'; +import { SignerService } from '../services/signer'; +import { SessionManager } from '../utils/session-manager'; +import { authTokens } from '../utils/auth-tokens'; +import { ProcessInfo, ProcessData, ProcessRoles, EOfficeStatus } from '../types'; +import { config } from '../config'; + +export class ProcessController { + static async getUserProcess(req: Request, res: Response): Promise { + console.log('Received request to get user process'); + try { + // Find the full token data which should contain the original idNotUser data + const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); + + if (!userAuth || !userAuth.idNotUser) { + return res.status(404).json({ + success: false, + message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' + }); + } + + const { pairingId } = req.query; + const signerClient = SignerService.getInstance(); + + // Now we ask signer if he knows a process for this office + let process: ProcessInfo | null = await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot); + + if (!process) { + console.log('No existing process found in signer, creating a new one'); + // We can use userInfo as data for the process + let uuid: string; + // Try to fetch existing UUID from database by idNot + try { + const result = await Database.query('SELECT uid FROM users WHERE "idNot" = $1', [userAuth.idNotUser.idNot]); + uuid = result.rows.length > 0 ? result.rows[0].uid : null; + } catch (error) { + console.error('Error fetching UUID by idNot:', error); + uuid = ''; + } + + // If no existing UUID found, generate a new one + if (!uuid) { + console.log('No existing UUID found in db, generating a new one'); + uuid = uuidv4(); + } + + const processData: ProcessData = { + uid: uuid, + utype: 'collaborator', + idNot: userAuth.idNotUser.idNot, + office: { + idNot: userAuth.idNotUser.office.idNot, + }, + role: userAuth.idNotUser.role, + office_role: userAuth.idNotUser.office_role, + contact: userAuth.idNotUser.contact, + }; + + console.log('processData', processData); + + const privateFields = Object.keys(processData); + const allFields = [...privateFields, 'roles']; + // Make those fields public + privateFields.splice(privateFields.indexOf('uid'), 1); + privateFields.splice(privateFields.indexOf('utype'), 1); + privateFields.splice(privateFields.indexOf('idNot'), 1); + + const getPairingIdResponse = await signerClient.getPairingId(); + const validatorId = getPairingIdResponse.pairingId; + + const roles: ProcessRoles = { + owner: { + members: [pairingId as string, validatorId], + validation_rules: [ + { + quorum: 0.1, + fields: allFields, + min_sig_member: 1, + } + ], + storages: [config.defaultStorage] + }, + apophis: { + members: [pairingId as string, validatorId], + validation_rules: [], + storages: [] + } + }; + + const newCollaboratorProcess = await signerClient.createProcess(processData, privateFields, roles); + console.log('Created new process:', newCollaboratorProcess); + // The createProcess returns a ServerResponse, we need to extract the process info + process = { processId: newCollaboratorProcess.processId, processData: newCollaboratorProcess.data }; + } else { + console.log('Using process:', process.processId); + } + + // We check that the process is commited, we do it if that's not the case + const allProcesses = await signerClient.getOwnedProcesses(); + if (allProcesses && process) { + const processStates = allProcesses.processes[process.processId].states; + const isNotCommited = processStates.length === 2 + && processStates[1].commited_in === processStates[0].commited_in; + if (isNotCommited) { + console.log('Process is not commited, committing it'); + await signerClient.validateState(process.processId, processStates[0].state_id); + } + + // We check that the pairingId is indeed part of the roles + let roles: ProcessRoles; + if (isNotCommited) { + // We take the first state + const firstState = processStates[0]; + roles = firstState.roles; + } else { + const tip = processStates[processStates.length - 1].commited_in; + const lastState = processStates.findLast((state: any) => state.commited_in !== tip); + roles = lastState.roles; + } + + if (!roles) { + throw new Error('No roles found'); + } else if (!roles['owner']) { + throw new Error('No owner role found'); + } + + if (!roles['owner'].members.includes(req.query.pairingId as string)) { + // We add the new pairingId to the owner role and commit + console.log('Adding new pairingId', req.query.pairingId, 'to owner role'); + roles['owner'].members.push(req.query.pairingId as string); + const updatedProcessReturn = await signerClient.updateProcess(process.processId, {}, [], roles); + const processId = updatedProcessReturn.updatedProcess.process_id; + const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; + await signerClient.notifyUpdate(processId, stateId); + await signerClient.validateState(processId, stateId); + } + } else { + throw new Error('No processes found'); + } + + // Return the stored idNotUser data without the authToken + res.json({ + success: true, + data: process + }); + + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la récupération des données utilisateur', + error: error.message + }); + } + } + + static async getOfficeProcess(req: Request, res: Response): Promise { + console.log('Received request to get office process'); + try { + // Find the full token data which should contain the original idNotUser data + const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); + + if (!userAuth || !userAuth.idNotUser) { + return res.status(404).json({ + success: false, + message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' + }); + } + + // If office is not ACTIVATED, we return a 404 error + if (userAuth.idNotUser.office.office_status !== EOfficeStatus.ACTIVATED) { + return res.status(404).json({ + success: false, + message: 'Office not activated' + }); + } + + const signerClient = SignerService.getInstance(); + + // Now we ask signer if he knows a process for this office + let process: ProcessInfo | null = await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot); + + if (!process) { + // Let's create it + // We directly use the office info as process data + const getPairingIdResponse = await signerClient.getPairingId(); + const validatorId = getPairingIdResponse.pairingId; + if (!validatorId) { + return res.status(400).json({ + success: false, + message: 'No validator id found' + }); + } + + let uuid: string; + // Try to fetch existing UUID from database by idNot + try { + const result = await Database.query('SELECT uid FROM offices WHERE "idNot" = $1', [userAuth.idNotUser.office.idNot]); + uuid = result.rows.length > 0 ? result.rows[0].uid : null; + } catch (error) { + console.error('Error fetching UUID by idNot:', error); + uuid = ''; + } + + // If no existing UUID found, generate a new one + if (!uuid) { + console.log('No existing UUID found in db, generating a new one'); + uuid = uuidv4(); + } + + const processData: ProcessData = { + uid: uuidv4(), + utype: 'office', + ...userAuth.idNotUser.office, + }; + + console.log('processData', processData); + + const privateFields = Object.keys(processData); + + const roles: ProcessRoles = { + owner: { + members: [validatorId], + validation_rules: [], + storages: [] + }, + apophis: { + members: [validatorId], + validation_rules: [], + storages: [] + } + }; + const newOfficeProcess = await signerClient.createProcess(processData, privateFields, roles); + process = { processId: newOfficeProcess.processId, processData: newOfficeProcess.data }; + console.log('Created new office process:', process); + } + + // We check that the process is commited, we do it if that's not the case + const allProcesses = await signerClient.getOwnedProcesses(); + if (allProcesses && process) { + const processStates = allProcesses.processes[process.processId].states; + const isNotCommited = processStates.length === 2 + && processStates[1].commited_in === processStates[0].commited_in; + if (isNotCommited) { + console.log('Process is not commited, committing it'); + await signerClient.validateState(process.processId, processStates[0].state_id); + } + } else { + throw new Error('No processes found'); + } + + // Return the stored idNotUser data without the authToken + res.json({ + success: true, + data: process + }); + + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la récupération des données utilisateur', + error: error.message + }); + } + } + + static async authenticateClient(req: Request, res: Response): Promise { + const { pairingId } = req.body; + + if (!pairingId) { + return res.status(400).json({ + success: false, + message: 'Missing pairingId' + }); + } + + try { + // This should be implemented properly based on your business logic + // const result = await signerClient.updateProcess(processId, newData, privateFields || [], roles || null); + + // Clean up the session after successful update + SessionManager.deleteSession(req.session!.id); + + res.json({ + success: true, + message: 'Client authentication successful', + data: {} + }); + } catch (error: any) { + console.error('Client authentication error:', error); + res.status(500).json({ + success: false, + message: 'Error during client authentication', + error: error.message + }); + } + } + + static async getPhoneNumberForEmail(req: Request, res: Response): Promise { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + message: 'Missing email' + }); + } + + const signerClient = SignerService.getInstance(); + const phoneNumber = await signerClient.getPhoneNumberForEmail(email); + + if (!phoneNumber) { + return res.status(400).json({ + success: false, + message: 'No phone number found for this email' + }); + } + + res.json({ + success: true, + message: 'Phone number retrieved successfully', + phoneNumber: phoneNumber + }); + } +} diff --git a/src/controllers/sms-improved.controller.ts b/src/controllers/sms-improved.controller.ts new file mode 100644 index 0000000..ec3f232 --- /dev/null +++ b/src/controllers/sms-improved.controller.ts @@ -0,0 +1,182 @@ +import { Request, Response } from 'express'; +import { SmsService } from '../services/sms'; +import { verificationCodes } from '../utils/verification-codes'; +import { SessionManager } from '../utils/session-manager'; +import { Validator } from '../utils/validation'; +import { Logger } from '../utils/logger'; +import { + RateLimitError, + BusinessRuleError, + ExternalServiceError, + AppError, + ErrorCode +} from '../types/errors'; +import { asyncHandler } from '../middleware/error-handler'; + +export class SmsImprovedController { + static sendCode = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + const { phoneNumber } = req.body; + + // Validate input + Validator.validate(req.body, Validator.phoneRules(), requestId); + + Logger.info('SMS code request initiated', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*') // Mask phone number + }); + + // Check rate limiting + const existingVerification = verificationCodes.get(phoneNumber); + if (existingVerification) { + const timeSinceLastSend = Date.now() - existingVerification.timestamp; + if (timeSinceLastSend < 30000) { // 30 seconds + throw new RateLimitError( + 'Veuillez attendre 30 secondes avant de demander un nouveau code', + requestId + ); + } + } + + // Generate and store code + const code = SmsService.generateCode(); + verificationCodes.set(phoneNumber, { + code, + timestamp: Date.now(), + attempts: 0 + }); + + // Send SMS + const message = `Votre code de vérification LeCoffre est : ${code}`; + const result = await SmsService.sendSms(phoneNumber, message); + + if (!result.success) { + Logger.error('SMS sending failed', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + error: result.error + }); + + throw new ExternalServiceError('SMS', result.error || 'Échec de l\'envoi du SMS'); + } + + Logger.info('SMS code sent successfully', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*') + }); + + res.json({ + success: true, + message: 'Code envoyé avec succès' + }); + }); + + static verifyCode = asyncHandler(async (req: Request, res: Response): Promise => { + const requestId = req.headers['x-request-id'] as string; + const { phoneNumber, code } = req.body; + + // Validate input + Validator.validate(req.body, [ + ...Validator.phoneRules(), + { + field: 'code', + required: true, + type: 'string', + minLength: 4, + maxLength: 6 + } + ], requestId); + + Logger.info('SMS code verification initiated', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*') + }); + + // Development shortcut + if (code === '1234') { + const sessionId = SessionManager.createSession(phoneNumber); + + Logger.info('Development code used', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + sessionId + }); + + res.json({ + success: true, + message: 'Code vérifié avec succès', + sessionId: sessionId + }); + return; + } + + const verification = verificationCodes.get(phoneNumber); + + if (!verification) { + throw new BusinessRuleError( + 'Aucun code n\'a été envoyé à ce numéro', + undefined, + requestId + ); + } + + // Check expiration (5 minutes) + if (Date.now() - verification.timestamp > 5 * 60 * 1000) { + verificationCodes.delete(phoneNumber); + throw new BusinessRuleError( + 'Le code a expiré', + undefined, + requestId + ); + } + + // Verify code + if (verification.code.toString() === code.toString()) { + verificationCodes.delete(phoneNumber); + + const sessionId = SessionManager.createSession(phoneNumber); + + Logger.info('SMS code verified successfully', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + sessionId + }); + + res.json({ + success: true, + message: 'Code vérifié avec succès', + sessionId: sessionId + }); + } else { + verification.attempts += 1; + + if (verification.attempts >= 3) { + verificationCodes.delete(phoneNumber); + + Logger.warn('Too many SMS verification attempts', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + attempts: verification.attempts + }); + + throw new BusinessRuleError( + 'Trop de tentatives. Veuillez demander un nouveau code', + undefined, + requestId + ); + } else { + Logger.warn('Invalid SMS code provided', { + requestId, + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + attempts: verification.attempts + }); + + throw new BusinessRuleError( + 'Code incorrect', + [{ field: 'code', value: code, constraints: ['Code de vérification incorrect'] }], + requestId + ); + } + } + }); +} diff --git a/src/controllers/sms.controller.ts b/src/controllers/sms.controller.ts new file mode 100644 index 0000000..f8c971c --- /dev/null +++ b/src/controllers/sms.controller.ts @@ -0,0 +1,126 @@ +import { Request, Response } from 'express'; +import { SmsService } from '../services/sms'; +import { verificationCodes } from '../utils/verification-codes'; +import { SessionManager } from '../utils/session-manager'; + +export class SmsController { + static async sendCode(req: Request, res: Response): Promise { + const { phoneNumber } = req.body; + + try { + // Check if a code already exists and is not expired + const existingVerification = verificationCodes.get(phoneNumber); + if (existingVerification) { + const timeSinceLastSend = Date.now() - existingVerification.timestamp; + if (timeSinceLastSend < 30000) { // 30 secondes + return res.status(429).json({ + success: false, + message: 'Veuillez attendre 30 secondes avant de demander un nouveau code' + }); + } + } + + // Generate a new code + const code = SmsService.generateCode(); + + // Store the code + verificationCodes.set(phoneNumber, { + code, + timestamp: Date.now(), + attempts: 0 + }); + + // Send the SMS + const message = `Votre code de vérification LeCoffre est : ${code}`; + const result = await SmsService.sendSms(phoneNumber, message); + + if (result.success) { + res.json({ + success: true, + message: 'Code envoyé avec succès', + }); + } else { + res.status(500).json({ + success: false, + message: 'Échec de l\'envoi du SMS via les deux fournisseurs' + }); + } + } catch (error: any) { + console.error('Error:', error); + res.status(500).json({ + success: false, + message: 'Erreur serveur lors de l\'envoi du code' + }); + } + } + + static verifyCode(req: Request, res: Response): any { + const { phoneNumber, code } = req.body; + + if (!code) { + return res.status(400).json({ + success: false, + message: 'Le code est requis' + }); + } + + // shortcut for development only + if (code === '1234') { + // Create a session for the verified user + const sessionId = SessionManager.createSession(phoneNumber); + + return res.json({ + success: true, + message: 'Code vérifié avec succès', + sessionId: sessionId + }); + } + + const verification = verificationCodes.get(phoneNumber); + + if (!verification) { + return res.status(400).json({ + success: false, + message: 'Aucun code n\'a été envoyé à ce numéro' + }); + } + + // Check if the code has not expired (5 minutes) + if (Date.now() - verification.timestamp > 5 * 60 * 1000) { + verificationCodes.delete(phoneNumber); + return res.status(400).json({ + success: false, + message: 'Le code a expiré' + }); + } + + // Check if the code is correct + if (verification.code.toString() === code.toString()) { + verificationCodes.delete(phoneNumber); + + // Create a session for the verified user + const sessionId = SessionManager.createSession(phoneNumber); + + res.json({ + success: true, + message: 'Code vérifié avec succès', + sessionId: sessionId + }); + } else { + verification.attempts += 1; + + if (verification.attempts >= 3) { + verificationCodes.delete(phoneNumber); + res.status(400).json({ + success: false, + message: 'Trop de tentatives. Veuillez demander un nouveau code' + }); + } else { + res.status(400).json({ + success: false, + message: 'Code incorrect' + }); + } + } + } +} diff --git a/src/controllers/stripe.controller.ts b/src/controllers/stripe.controller.ts new file mode 100644 index 0000000..097dbeb --- /dev/null +++ b/src/controllers/stripe.controller.ts @@ -0,0 +1,113 @@ +import { Request, Response } from 'express'; +import Stripe from 'stripe'; +import { StripeService } from '../services/stripe'; +import { stripeConfig } from '../config/stripe'; + +export class StripeController { + private static stripeService = new StripeService(); + + // Only for test + static async createTestSubscription(req: Request, res: Response) { + try { + const result = await StripeController.stripeService.createTestSubscription(); + res.json({ + success: true, + data: result + }); + } catch (error: any) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la création de l\'abonnement de test', + error: { + message: error.message, + type: error.type, + code: error.code + } + }); + } + } + + static async createCheckoutSession(req: Request, res: Response) { + try { + const session = await StripeController.stripeService.createCheckoutSession(req.body, req.body.frequency); + res.json({ success: true, sessionId: session.id }); + } catch (error) { + console.error('Error creating checkout:', error); + res.status(500).json({ + success: false, + message: 'Erreur lors de la création de la session de paiement' + }); + } + } + + static async getSubscription(req: Request, res: Response) { + try { + const subscription = await StripeController.stripeService.getSubscription(req.params.id); + res.json({ success: true, subscription }); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la récupération de l\'abonnement' + }); + } + } + + static async createPortalSession(req: Request, res: Response) { + try { + const session = await StripeController.stripeService.createPortalSession(req.params.id); + res.json({ success: true, url: session.url }); + } catch (error) { + res.status(500).json({ + success: false, + message: 'Erreur lors de la création de la session du portail' + }); + } + } + + static async handleWebhook(req: Request, res: Response): Promise { + const sig = req.headers['stripe-signature'] as string; + let event: Stripe.Event; + + try { + event = Stripe.webhooks.constructEvent(req.body, sig, stripeConfig.STRIPE_WEBHOOK_SECRET!); + } catch (err: any) { + return res.status(400).send(`Webhook Error: ${err.message}`); + } + + try { + switch (event.type) { + case 'checkout.session.completed': + const session = event.data.object as Stripe.Checkout.Session; + if (session.status === 'complete') { + const subscription = JSON.parse(session.metadata!.subscription); + // Stock subscription (create process) + console.log('New subscription:', subscription); + } + break; + + case 'invoice.payment_succeeded': + const invoice = event.data.object as Stripe.Invoice; + if (['subscription_update', 'subscription_cycle'].includes(invoice.billing_reason!)) { + const subscription = await StripeController.stripeService.getSubscription((invoice as any).subscription); + // Update subscription (update process) + console.log('Subscription update:', subscription); + } + break; + + case 'customer.subscription.deleted': + const deletedSubscription = event.data.object as Stripe.Subscription; + // Delete subscription (update process to delete) + console.log('Subscription deleted:', deletedSubscription.id); + break; + } + + res.json({ received: true }); + } catch (error) { + console.error('Webhook error:', error); + res.status(500).json({ + success: false, + message: 'Error processing webhook' + }); + } + } +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..4742530 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,46 @@ +import { Request, Response, NextFunction } from 'express'; +import { authTokens } from '../utils/auth-tokens'; + +// IdNot Authentication Middleware +export const authenticateIdNot = (req: Request, res: Response, next: NextFunction): any => { + const authToken = req.headers['authorization']?.replace('Bearer ', '') || req.headers['x-auth-token'] as string || req.body.authToken; + + if (!authToken) { + return res.status(401).json({ + success: false, + message: 'Token d\'authentification requis' + }); + } + + // Find the user by auth token + const userAuth = authTokens.find(auth => auth.authToken === authToken); + + if (!userAuth) { + return res.status(401).json({ + success: false, + message: 'Token d\'authentification invalide' + }); + } + + // Check if token has expired + if (Date.now() > userAuth.expiresAt) { + // Remove expired token + const tokenIndex = authTokens.findIndex(auth => auth.authToken === authToken); + if (tokenIndex > -1) { + authTokens.splice(tokenIndex, 1); + } + + return res.status(401).json({ + success: false, + message: 'Token d\'authentification expiré' + }); + } + + // Add user info to request + req.idNotUser = { + idNot: userAuth.idNot, + authToken: userAuth.authToken + }; + + next(); +}; diff --git a/src/middleware/error-handler.ts b/src/middleware/error-handler.ts new file mode 100644 index 0000000..4d055f9 --- /dev/null +++ b/src/middleware/error-handler.ts @@ -0,0 +1,147 @@ +import { Request, Response, NextFunction } from 'express'; +import { AppError, ErrorCode } from '../types/errors'; +import { Logger } from '../utils/logger'; + +export const errorHandler = ( + error: Error | AppError, + req: Request, + res: Response, + next: NextFunction +): void => { + const requestId = req.headers['x-request-id'] as string || 'unknown'; + + // If it's already an AppError, use it directly + if (error instanceof AppError) { + Logger.error('Application error occurred', { + requestId, + error: { + code: error.code, + message: error.message, + statusCode: error.statusCode, + details: error.details, + stack: error.stack + }, + request: { + method: req.method, + url: req.url, + userAgent: req.get('User-Agent'), + ip: req.ip + } + }); + + res.status(error.statusCode).json(error.toJSON()); + return; + } + + // Handle known error types + if (error.name === 'ValidationError') { + const appError = new AppError( + ErrorCode.VALIDATION_ERROR, + 'Erreur de validation', + 400, + true, + undefined, + requestId + ); + + Logger.error('Validation error', { + requestId, + originalError: error.message, + stack: error.stack + }); + + res.status(400).json(appError.toJSON()); + return; + } + + if (error.name === 'UnauthorizedError') { + const appError = new AppError( + ErrorCode.UNAUTHORIZED, + 'Non autorisé', + 401, + true, + undefined, + requestId + ); + + Logger.error('Unauthorized access attempt', { + requestId, + error: error.message, + request: { + method: req.method, + url: req.url, + ip: req.ip + } + }); + + res.status(401).json(appError.toJSON()); + return; + } + + // Handle database errors + if (error.message?.includes('database') || error.message?.includes('connection')) { + const appError = new AppError( + ErrorCode.DATABASE_ERROR, + 'Erreur de base de données', + 500, + true, + undefined, + requestId + ); + + Logger.error('Database error', { + requestId, + error: error.message, + stack: error.stack + }); + + res.status(500).json(appError.toJSON()); + return; + } + + // Generic server error + const appError = new AppError( + ErrorCode.INTERNAL_SERVER_ERROR, + 'Erreur interne du serveur', + 500, + false, // Non-operational error + undefined, + requestId + ); + + Logger.error('Unhandled error', { + requestId, + error: { + name: error.name, + message: error.message, + stack: error.stack + }, + request: { + method: req.method, + url: req.url, + body: req.body, + userAgent: req.get('User-Agent'), + ip: req.ip + } + }); + + res.status(500).json(appError.toJSON()); +}; + +// Middleware to catch async errors +export const asyncHandler = (fn: Function) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +// Request ID middleware +export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => { + const requestId = req.headers['x-request-id'] as string || + `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + req.headers['x-request-id'] = requestId; + res.setHeader('X-Request-ID', requestId); + + next(); +}; diff --git a/src/middleware/session.ts b/src/middleware/session.ts new file mode 100644 index 0000000..4940478 --- /dev/null +++ b/src/middleware/session.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from 'express'; +import { SessionManager } from '../utils/session-manager'; + +// Middleware to validate session +export const validateSession = (req: Request, res: Response, next: NextFunction): any => { + const sessionId = req.headers['x-session-id'] as string || req.body.sessionId; + + if (!sessionId) { + return res.status(401).json({ + success: false, + message: 'Session ID requis' + }); + } + + const session = SessionManager.getSession(sessionId); + if (!session) { + return res.status(401).json({ + success: false, + message: 'Session invalide ou expirée' + }); + } + + req.session = session; + next(); +}; diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts new file mode 100644 index 0000000..e8b26ca --- /dev/null +++ b/src/middleware/validation.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { Validators } from '../utils/validators'; + +// Phone number validation middleware +export const validatePhoneNumber = (req: Request, res: Response, next: NextFunction): any => { + const { phoneNumber } = req.body; + + if (!phoneNumber) { + return res.status(400).json({ + success: false, + message: 'Le numéro de téléphone est requis' + }); + } + + if (!Validators.validatePhoneNumber(phoneNumber)) { + return res.status(400).json({ + success: false, + message: 'Format de numéro de téléphone invalide' + }); + } + + next(); +}; + +// Email validation middleware +export const validateEmail = (req: Request, res: Response, next: NextFunction): any => { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + success: false, + message: 'L\'adresse email est requise' + }); + } + + if (!Validators.validateEmail(email)) { + return res.status(400).json({ + success: false, + message: 'Format d\'email invalide' + }); + } + + next(); +}; + +// Subscription validation middleware +export const validateSubscription = (req: Request, res: Response, next: NextFunction): any => { + const { type, seats, frequency } = req.body; + + const validation = Validators.validateSubscription(type, seats, frequency); + + if (!validation.valid) { + return res.status(400).json({ + success: false, + message: validation.message + }); + } + + next(); +}; diff --git a/src/routes/email.routes.ts b/src/routes/email.routes.ts new file mode 100644 index 0000000..73ca5ac --- /dev/null +++ b/src/routes/email.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { EmailController } from '../controllers/email.controller'; +import { validateEmail } from '../middleware/validation'; + +const router = Router(); + +router.post('/send-email', validateEmail, EmailController.sendEmail); +router.post('/subscribe-to-list', validateEmail, EmailController.subscribeToList); +router.post('/send_reminder', EmailController.sendReminder); + +export { router as emailRoutes }; diff --git a/src/routes/health.routes.ts b/src/routes/health.routes.ts new file mode 100644 index 0000000..6a18cdb --- /dev/null +++ b/src/routes/health.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { HealthController } from '../controllers/health.controller'; + +const router = Router(); + +router.get('/health', HealthController.getHealth); + +export { router as healthRoutes }; diff --git a/src/routes/idnot.routes.ts b/src/routes/idnot.routes.ts new file mode 100644 index 0000000..6b6750f --- /dev/null +++ b/src/routes/idnot.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { IdNotController } from '../controllers/idnot.controller'; +import { authenticateIdNot } from '../middleware/auth'; + +const router = Router(); + +// Public routes +router.get('/user/rattachements', IdNotController.getUserRattachements); +router.get('/office/rattachements', IdNotController.getOfficeRattachements); +router.post('/auth/:code', IdNotController.authenticate); + +// Protected routes +router.get('/user', authenticateIdNot, IdNotController.getCurrentUser); +router.post('/logout', authenticateIdNot, IdNotController.logout); +router.get('/validate', authenticateIdNot, IdNotController.validateToken); + +export { router as idnotRoutes }; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..8e9d7b8 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { healthRoutes } from './health.routes'; +import { smsRoutes } from './sms.routes'; +import { idnotRoutes } from './idnot.routes'; +import { emailRoutes } from './email.routes'; +import { stripeRoutes } from './stripe.routes'; +import { processRoutes } from './process.routes'; + +const router = Router(); + +// Mount routes +router.use('/api/v1', healthRoutes); +router.use('/api', smsRoutes); +router.use('/api/v1/idnot', idnotRoutes); +router.use('/api/v1/process', processRoutes); +router.use('/api', emailRoutes); +router.use('/api', stripeRoutes); + +export { router as routes }; diff --git a/src/routes/process-improved.routes.ts b/src/routes/process-improved.routes.ts new file mode 100644 index 0000000..d68edb0 --- /dev/null +++ b/src/routes/process-improved.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { ProcessImprovedController } from '../controllers/process-improved.controller'; +import { authenticateIdNot } from '../middleware/auth'; +import { validateSession } from '../middleware/session'; + +const router = Router(); + +// Health check routes (public) +router.get('/health/signer', ProcessImprovedController.getSignerHealth); +router.post('/admin/signer/reconnect', ProcessImprovedController.forceSignerReconnect); // Should be protected in production + +// IdNot protected routes +router.get('/user', authenticateIdNot, ProcessImprovedController.getUserProcess); +router.get('/office', authenticateIdNot, ProcessImprovedController.getOfficeProcess); + +// Customer auth routes (session protected) +router.post('/customer/auth/client-auth', validateSession, ProcessImprovedController.authenticateClient); +router.post('/customer/auth/get-phone-number-for-email', validateSession, ProcessImprovedController.getPhoneNumberForEmail); + +export { router as processImprovedRoutes }; diff --git a/src/routes/process.routes.ts b/src/routes/process.routes.ts new file mode 100644 index 0000000..6d11469 --- /dev/null +++ b/src/routes/process.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { ProcessController } from '../controllers/process.controller'; +import { authenticateIdNot } from '../middleware/auth'; +import { validateSession } from '../middleware/session'; + +const router = Router(); + +// IdNot protected routes +router.get('/user', authenticateIdNot, ProcessController.getUserProcess); +router.get('/office', authenticateIdNot, ProcessController.getOfficeProcess); + +// Customer auth routes (session protected) +router.post('/customer/auth/client-auth', validateSession, ProcessController.authenticateClient); +router.post('/customer/auth/get-phone-number-for-email', validateSession, ProcessController.getPhoneNumberForEmail); + +export { router as processRoutes }; diff --git a/src/routes/sms.routes.ts b/src/routes/sms.routes.ts new file mode 100644 index 0000000..b0e88a0 --- /dev/null +++ b/src/routes/sms.routes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { SmsController } from '../controllers/sms.controller'; +import { validatePhoneNumber } from '../middleware/validation'; + +const router = Router(); + +router.post('/send-code', validatePhoneNumber, SmsController.sendCode); +router.post('/verify-code', validatePhoneNumber, SmsController.verifyCode); + +export { router as smsRoutes }; diff --git a/src/routes/stripe.routes.ts b/src/routes/stripe.routes.ts new file mode 100644 index 0000000..fe8dfa7 --- /dev/null +++ b/src/routes/stripe.routes.ts @@ -0,0 +1,18 @@ +import express, { Router } from 'express'; +import { StripeController } from '../controllers/stripe.controller'; +import { validateSubscription } from '../middleware/validation'; + +const router = Router(); + +// Test route +router.post('/test/create-subscription', StripeController.createTestSubscription); + +// Subscription routes +router.post('/subscriptions/checkout', validateSubscription, StripeController.createCheckoutSession); +router.get('/subscriptions/:id', StripeController.getSubscription); +router.post('/subscriptions/:id/portal', StripeController.createPortalSession); + +// Webhook route (requires raw body) +router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), StripeController.handleWebhook); + +export { router as stripeRoutes }; diff --git a/src/server.ts b/src/server.ts index 0f26205..c2c0763 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,727 +1,70 @@ -import express, { Request, Response, NextFunction } from 'express'; +import express from 'express'; import cors from 'cors'; -import fetch from 'node-fetch'; -import { v4 as uuidv4 } from 'uuid'; -import ovh = require('ovh'); -import mailchimp = require('@mailchimp/mailchimp_transactional'); -import Stripe from 'stripe'; import { Database } from './database'; -import { SDKSignerClient, ClientConfig, ServerResponse } from 'sdk-signer-client'; -import * as dotenv from 'dotenv'; -import { - ECivility, - EOfficeStatus, - EIdnotRole, - ETemplates, - IdNotUser, - AuthToken, - Session, - VerificationCode, - SmsConfig, - EmailConfig, - StripeConfig, - Subscription, - PendingEmail, - ProcessRoles, - ProcessData, - ProcessInfo -} from './types'; - -dotenv.config(); +import { config } from './config'; +import { routes } from './routes'; +import { SignerImprovedService } from './services/signer-improved'; +import { SessionManager } from './utils/session-manager'; +import { EmailService } from './services/email'; +import { authTokens } from './utils/auth-tokens'; +import { errorHandler, requestIdMiddleware } from './middleware/error-handler'; +import { Logger } from './utils/logger'; // Initialisation de l'application Express const app = express(); -const PORT = process.env.PORT || 8080; -const DEFAULT_STORAGE = process.env.DEFAULT_STORAGE || 'https://dev3.4nkweb.com/storage'; +const PORT = config.port; + +// Request ID middleware (must be first) +app.use(requestIdMiddleware); // Configuration CORS -app.use(cors({ - origin: 'http://localhost:3000', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'x-session-id', 'Authorization'], - credentials: true -})); +app.use(cors(config.cors)); app.use(express.json()); -const authTokens: AuthToken[] = []; - -function getOfficeStatus(statusName: string): EOfficeStatus { - switch (statusName) { - case "Pourvu": - return EOfficeStatus.ACTIVATED; - case "Pourvu mais décédé": - return EOfficeStatus.ACTIVATED; - case "Sans titulaire": - return EOfficeStatus.ACTIVATED; - case "Vacance": - return EOfficeStatus.ACTIVATED; - case "En activité": - return EOfficeStatus.ACTIVATED; - default: - return EOfficeStatus.DESACTIVATED; - } -} - -function getOfficeRole(roleName: string): { name: string } | null { - switch (roleName) { - case EIdnotRole.NOTAIRE_TITULAIRE: - return { name: 'Notaire' }; - case EIdnotRole.NOTAIRE_ASSOCIE: - return { name: 'Notaire' }; - case EIdnotRole.NOTAIRE_SALARIE: - return { name: 'Notaire' }; - case EIdnotRole.COLLABORATEUR: - return { name: 'Collaborateur' }; - case EIdnotRole.SUPPLEANT: - return { name: 'Collaborateur' }; - case EIdnotRole.ADMINISTRATEUR: - return { name: 'Collaborateur' }; - case EIdnotRole.CURATEUR: - return { name: 'Collaborateur' }; - default: - return null; - } -} - -function getRole(roleName: string): { name: string } { - switch (roleName) { - case EIdnotRole.NOTAIRE_TITULAIRE: - return { name: 'admin' }; - case EIdnotRole.NOTAIRE_ASSOCIE: - return { name: 'admin' }; - case EIdnotRole.NOTAIRE_SALARIE: - return { name: 'notary' }; - case EIdnotRole.COLLABORATEUR: - return { name: 'notary' }; - case EIdnotRole.SUPPLEANT: - return { name: 'notary' }; - case EIdnotRole.ADMINISTRATEUR: - return { name: 'admin' }; - case EIdnotRole.CURATEUR: - return { name: 'notary' }; - default: - return { name: 'default' }; - } -} - -function getCivility(civility: string): ECivility { - switch (civility) { - case 'Monsieur': - return ECivility.MALE; - case 'Madame': - return ECivility.FEMALE; - default: - return ECivility.OTHERS; - } -} - -app.get('/api/v1/health', (req: Request, res: Response) => { - res.json({ message: 'OK' }); -}); - -app.get('/api/v1/db/:tableName', async (req: Request, res: Response): Promise => { - try { - const { tableName } = req.params; - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 10; - const offset = (page - 1) * limit; - - // Validation du nom de table pour éviter les injections SQL - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { - return res.status(400).json({ - success: false, - message: 'Nom de table invalide' - }); - } - - // Compter le total d'enregistrements - const countResult = await Database.query(`SELECT COUNT(*) as total FROM ${tableName}`); - const total = parseInt(countResult.rows[0].total); - - if (tableName === 'rules_groups') { - const rulesGroups = await Promise.all((await Database.query(`SELECT * FROM ${tableName} ORDER BY 1 LIMIT $1 OFFSET $2`, [limit, offset])).rows.map(async (ruleGroup: any) => { - const result = await Database.query(`SELECT a.* FROM rules AS a JOIN "_RulesGroupsHasRules" as b ON b."A" = a.uid AND b."B" = $1`, [ruleGroup.uid]); - return { - uid: ruleGroup.uid, - name: ruleGroup.name, - rules: result.rows.map((rule: any) => { - return { - uid: rule.uid - } - }), - created_at: ruleGroup.created_at, - updated_at: ruleGroup.updated_at - }; - })); - - res.json({ - success: true, - data: rulesGroups, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - hasNextPage: page < Math.ceil(total / limit), - hasPrevPage: page > 1 - }, - table: tableName, - timestamp: new Date().toISOString() - }); - } else if (tableName === 'office_roles') { - const officeRoles = await Promise.all((await Database.query(`SELECT * FROM ${tableName} ORDER BY 1 LIMIT $1 OFFSET $2`, [limit, offset])).rows.map(async (officeRole: any) => { - const result = await Database.query(`SELECT a.* FROM rules AS a JOIN "_OfficeRolesHasRules" as b ON b."B" = a.uid AND b."A" = $1`, [officeRole.uid]); - return { - uid: officeRole.uid, - name: officeRole.name, - office: { - uid: officeRole.office_uid - }, - rules: result.rows.map((rule: any) => { - return { - uid: rule.uid - } - }), - created_at: officeRole.created_at, - updated_at: officeRole.updated_at - }; - })); - - res.json({ - success: true, - data: officeRoles, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - hasNextPage: page < Math.ceil(total / limit), - hasPrevPage: page > 1 - }, - table: tableName, - timestamp: new Date().toISOString() - }); - } else { - const result = await Database.query(`SELECT * FROM ${tableName} ORDER BY 1 LIMIT $1 OFFSET $2`, [limit, offset]); - - res.json({ - success: true, - data: result.rows, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - hasNextPage: page < Math.ceil(total / limit), - hasPrevPage: page > 1 - }, - table: tableName, - timestamp: new Date().toISOString() - }); - } - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de l\'exécution de la requête', - error: error.message - }); - } -}); - -// Returns all the office data for each rattachement of the user -app.get('/api/v1/idnot/user/rattachements', async (req: Request, res: Response): Promise => { - const { idNot } = req.query; - - const searchParams = new URLSearchParams({ - key: process.env.IDNOT_API_KEY || '', - deleted: 'false' - }); - - const url = `${process.env.IDNOT_ANNUARY_BASE_URL}/api/pp/v2/personnes/${idNot}/rattachements?` + searchParams; - - const json = await ( - await fetch(url, { - method: 'GET' - }) - ).json(); - - // if json.result.length is 0, return 404 - if (json.result.length === 0) { - return res.status(404).json({ - success: false, - message: 'No rattachements found' - }); - } - - // Iterate over all results and get the office data by calling the entiteUrl endpoint - const officeData = await Promise.all(json.result.map(async (result: any) => { - const officeData = await ( - await fetch(`${process.env.IDNOT_ANNUARY_BASE_URL}${result.entiteUrl}?` + searchParams, { - method: 'GET' - }) - ).json(); - return officeData; - })); - - res.json(officeData); -}); - -// Returns the user data for each user rattached to an entity -app.get('/api/v1/idnot/office/rattachements', async (req: Request, res: Response) => { - const { idNot } = req.query; - - const searchParams = new URLSearchParams({ - key: process.env.IDNOT_API_KEY || '', - deleted: 'false' - }); - - const url = `${process.env.IDNOT_ANNUARY_BASE_URL}/api/pp/v2/entites/${idNot}/personnes?` + searchParams; - - const json = await ( - await fetch(url, { - method: 'GET' - }) - ).json(); - - res.json(json); -}); - -app.post('/api/v1/idnot/auth/:code', async (req: Request, res: Response): Promise => { - const code = req.params.code; - - try { - const params = { - client_id: 'B3CE56353EDB15A9', - client_secret: '3F733549E879878344B6C949B366BB5CDBB2DB5B7F7AB7EBBEBB0F0DD0776D1C', - redirect_uri: 'http://local.lecoffreio.4nkweb:3000/authorized-client', - grant_type: 'authorization_code', - code: code - }; - - const tokens = await ( - await fetch('https://qual-connexion.idnot.fr/user/IdPOAuth2/token/idnot_idp_v1', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(params).toString() - }) - ).json(); - - const jwt = tokens.id_token; - if (!jwt) { - console.error('jwt not defined'); - return; - } - const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64').toString('utf8')); - - const searchParams = new URLSearchParams({ - key: process.env.IDNOT_API_KEY || '' - }); - - let userData: any; - try { - userData = await ( - await fetch(`https://qual-api.notaires.fr/annuaire/api/pp/v2/rattachements/${payload.profile_idn}?` + searchParams, { - method: 'GET' - }) - ).json(); - } catch (error) { - console.error('Error fetching ' + `https://qual-api.notaires.fr/annuaire/api/pp/v2/rattachements/${payload.profile_idn}`, error); - return; - } - if (!userData || !userData.statutDuRattachement || userData.entite.typeEntite.name !== 'office') { - console.error('User not attached to an office (May be a partner)'); - return; - } - - let officeLocationData: any; - try { - officeLocationData = (await ( - await fetch(`https://qual-api.notaires.fr/annuaire${userData.entite.locationsUrl}?` + searchParams, - { - method: 'GET' - }) - ).json()); - } catch (error) { - console.error('Error fetching' + `https://qual-api.notaires.fr/annuaire${userData.entite.locationsUrl}`, error); - return; - } - if (!officeLocationData || !officeLocationData.result || officeLocationData.result.length === 0) { - console.error('Office location data not found'); - return; - } - - const idNotUser: IdNotUser = { - idNot: payload.sub, - office: { - idNot: payload.entity_idn, - name: userData.entite.denominationSociale ?? userData.entite.codeCrpcen, - crpcen: userData.entite.codeCrpcen, - office_status: 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: 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: getCivility(userData.personne.civilite) - }, - office_role: getOfficeRole(userData.typeLien.name) - }; - - if (!idNotUser.contact.email) { - console.error('User pro email empty'); - return; - } - - const authToken = uuidv4(); - const tokenData: AuthToken = { - idNot: idNotUser.idNot, - authToken, - idNotUser: idNotUser, // Store the full user data - pairingId: null, // To be set on a separate call - defaultStorage: null, // To be set on a separate call - createdAt: Date.now(), - expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours - }; - authTokens.push(tokenData); - - res.json({ idNotUser, authToken }); - } catch (error: any) { - res.status(500).json({ - error: 'Internal Server Error', - message: error.message - }); - } -}); - -//------------------------------------ SMS Section ----------------------------------------- - -const configSms: SmsConfig = { - // OVH config - OVH_APP_KEY: process.env.OVH_APP_KEY, - OVH_APP_SECRET: process.env.OVH_APP_SECRET, - OVH_CONSUMER_KEY: process.env.OVH_CONSUMER_KEY, - OVH_SMS_SERVICE_NAME: process.env.OVH_SMS_SERVICE_NAME, - - // SMS Factor config - SMS_FACTOR_TOKEN: process.env.SMS_FACTOR_TOKEN, - - PORT: parseInt(process.env.PORT || '8080') -}; - -// Codes storage -const verificationCodes = new Map(); - -// Service SMS -class SmsService { - static generateCode(): number { - return Math.floor(100000 + Math.random() * 900000); - } - - // OVH Service - static sendSmsWithOvh(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { - return new Promise((resolve, reject) => { - const ovhClient = ovh({ - appKey: configSms.OVH_APP_KEY!, - appSecret: configSms.OVH_APP_SECRET!, - consumerKey: configSms.OVH_CONSUMER_KEY! - }); - - ovhClient.request('POST', `/sms/${configSms.OVH_SMS_SERVICE_NAME}/jobs`, { - message: message, - receivers: [phoneNumber], - senderForResponse: false, - sender: 'not.IT Fact', - noStopClause: true - }, (error: any, result: any) => { - if (error) { - console.error('Erreur OVH SMS:', error); - resolve({ success: false, error: 'Échec de l\'envoi du SMS via OVH' }); - } else { - resolve({ success: true }); - } - }); - }); - } - - // SMS Factor Service - static async sendSmsWithSmsFactor(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { - try { - const url = new URL('https://api.smsfactor.com/send/simulate'); - url.searchParams.append('to', phoneNumber); - url.searchParams.append('text', message); - url.searchParams.append('sender', 'LeCoffre'); - url.searchParams.append('token', configSms.SMS_FACTOR_TOKEN!); - - const response = await fetch(url.toString()); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return { success: true }; - } catch (error) { - console.error('Erreur SMS Factor:', error); - return { success: false, error: 'Échec de l\'envoi du SMS via SMS Factor' }; - } - } - - // Main method - static async sendSms(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { - // Try first with OVH - const ovhResult = await this.sendSmsWithOvh(phoneNumber, message); - - if (ovhResult.success) { - return ovhResult; - } - - // If OVH fails, try with SMS Factor - console.log('OVH SMS failed, trying SMS Factor...'); - return await this.sendSmsWithSmsFactor(phoneNumber, message); - } -} - -// Phone number validation middleware -const validatePhoneNumber = (req: Request, res: Response, next: NextFunction): any => { - const { phoneNumber } = req.body; - - if (!phoneNumber) { - return res.status(400).json({ - success: false, - message: 'Le numéro de téléphone est requis' - }); - } - - // Validation basique du format - const phoneRegex = /^(\+[1-9]\d{1,14}|0\d{9,14})$/; - if (!phoneRegex.test(phoneNumber)) { - return res.status(400).json({ - success: false, - message: 'Format de numéro de téléphone invalide' - }); - } - - next(); -}; - -// Routes -app.post('/api/send-code', validatePhoneNumber, async (req: Request, res: Response): Promise => { - const { phoneNumber } = req.body; - - try { - // Check if a code already exists and is not expired - const existingVerification = verificationCodes.get(phoneNumber); - if (existingVerification) { - const timeSinceLastSend = Date.now() - existingVerification.timestamp; - if (timeSinceLastSend < 30000) { // 30 secondes - return res.status(429).json({ - success: false, - message: 'Veuillez attendre 30 secondes avant de demander un nouveau code' - }); - } - } - - // Generate a new code - const code = SmsService.generateCode(); - - // Store the code - verificationCodes.set(phoneNumber, { - code, - timestamp: Date.now(), - attempts: 0 - }); - - // Send the SMS - const message = `Votre code de vérification LeCoffre est : ${code}`; - const result = await SmsService.sendSms(phoneNumber, message); - - if (result.success) { - res.json({ - success: true, - message: 'Code envoyé avec succès', - }); - } else { - res.status(500).json({ - success: false, - message: 'Échec de l\'envoi du SMS via les deux fournisseurs' - }); - } - } catch (error: any) { - console.error('Error:', error); - res.status(500).json({ - success: false, - message: 'Erreur serveur lors de l\'envoi du code' - }); - } -}); - -//------------------------------------ Signer Client Integration ----------------------------------------- - -// Signer client configuration -const signerConfig: ClientConfig = { - url: process.env.SIGNER_WS_URL || 'ws://localhost:9090', - apiKey: process.env.SIGNER_API_KEY || 'your-api-key-change-this', - timeout: 30000, - reconnectInterval: 5000, - maxReconnectAttempts: 3 -}; - -// Initialize signer client -const signerClient = new SDKSignerClient(signerConfig); - -// Session storage for verified users -const verifiedSessions = new Map(); - -// Session management -class SessionManager { - static generateSessionId(): string { - return uuidv4(); - } - - static createSession(phoneNumber: string, userData: any = {}): string { - const sessionId = this.generateSessionId(); - const session: Session = { - id: sessionId, - phoneNumber, - userData, - createdAt: Date.now(), - expiresAt: Date.now() + (1 * 60 * 1000) // 1 minute - }; - - verifiedSessions.set(sessionId, session); - return sessionId; - } - - static getSession(sessionId: string): Session | null { - const session = verifiedSessions.get(sessionId); - if (!session) return null; - - if (Date.now() > session.expiresAt) { - verifiedSessions.delete(sessionId); - return null; - } - - return session; - } - - static deleteSession(sessionId: string): void { - verifiedSessions.delete(sessionId); - } - - static cleanupExpiredSessions(): void { - const now = Date.now(); - for (const [sessionId, session] of verifiedSessions) { - if (now > session.expiresAt) { - verifiedSessions.delete(sessionId); - } - } - } -} - -app.post('/api/v1/verify-code', validatePhoneNumber, (req: Request, res: Response): any => { - const { phoneNumber, code } = req.body; - - if (!code) { - return res.status(400).json({ - success: false, - message: 'Le code est requis' - }); - } - - // shortcurt for development only - if (code === '1234') { - // Create a session for the verified user - const sessionId = SessionManager.createSession(phoneNumber); - - return res.json({ - success: true, - message: 'Code vérifié avec succès', - sessionId: sessionId - }); - } - - const verification = verificationCodes.get(phoneNumber); - - if (!verification) { - return res.status(400).json({ - success: false, - message: 'Aucun code n\'a été envoyé à ce numéro' - }); - } - - // Check if the code has not expired (5 minutes) - if (Date.now() - verification.timestamp > 5 * 60 * 1000) { - verificationCodes.delete(phoneNumber); - return res.status(400).json({ - success: false, - message: 'Le code a expiré' - }); - } - - // Check if the code is correct - if (verification.code.toString() === code.toString()) { - verificationCodes.delete(phoneNumber); - - // Create a session for the verified user - const sessionId = SessionManager.createSession(phoneNumber); - - res.json({ - success: true, - message: 'Code vérifié avec succès', - sessionId: sessionId - }); - } else { - verification.attempts += 1; - - if (verification.attempts >= 3) { - verificationCodes.delete(phoneNumber); - res.status(400).json({ - success: false, - message: 'Trop de tentatives. Veuillez demander un nouveau code' - }); - } else { - res.status(400).json({ - success: false, - message: 'Code incorrect' - }); - } - } -}); - -//------------------------------------ End of SMS Section ------------------------------------ - -// Middleware to validate session -const validateSession = (req: Request, res: Response, next: NextFunction): any => { - const sessionId = req.headers['x-session-id'] as string || req.body.sessionId; +// Request logging middleware +app.use((req, res, next) => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + Logger.logRequest(req, res, duration); + }); - if (!sessionId) { - return res.status(401).json({ - success: false, - message: 'Session ID requis' - }); - } - - const session = SessionManager.getSession(sessionId); - if (!session) { - return res.status(401).json({ - success: false, - message: 'Session invalide ou expirée' - }); - } - - req.session = session; next(); -}; +}); + +// Use routes from the reorganized structure +app.use(routes); + +// Error handling middleware (must be last) +app.use(errorHandler); + +// Initialize signer service with enhanced reconnection logic +(async () => { + try { + const result = await SignerImprovedService.initialize(); + if (result.success) { + Logger.info('Signer service initialized'); + } else { + Logger.error('Failed to initialize signer service', { + error: result.error?.message || 'Unknown error' + }); + } + } catch (error) { + Logger.error('Critical error during signer initialization', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } +})(); + +// Set up signer connection monitoring +SignerImprovedService.onConnectionChange((connected) => { + if (connected) { + Logger.info('Signer connected'); + } else { + Logger.warn('Signer disconnected'); + } +}); // Cleanup expired sessions every 5 minutes setInterval(() => { @@ -746,914 +89,11 @@ setInterval(() => { } }, 60 * 60 * 1000); // Every hour -// IdNot Authentication Middleware -const authenticateIdNot = (req: Request, res: Response, next: NextFunction): any => { - const authToken = req.headers['authorization']?.replace('Bearer ', '') || req.headers['x-auth-token'] as string || req.body.authToken; - - if (!authToken) { - return res.status(401).json({ - success: false, - message: 'Token d\'authentification requis' - }); - } - - // Find the user by auth token - const userAuth = authTokens.find(auth => auth.authToken === authToken); - - if (!userAuth) { - return res.status(401).json({ - success: false, - message: 'Token d\'authentification invalide' - }); - } - - // Check if token has expired - if (Date.now() > userAuth.expiresAt) { - // Remove expired token - const tokenIndex = authTokens.findIndex(auth => auth.authToken === authToken); - if (tokenIndex > -1) { - authTokens.splice(tokenIndex, 1); - } - - return res.status(401).json({ - success: false, - message: 'Token d\'authentification expiré' - }); - } - - // Add user info to request - req.idNotUser = { - idNot: userAuth.idNot, - authToken: userAuth.authToken - }; - - next(); -}; - -// Connect to signer on startup -(async () => { - try { - await signerClient.connect(); - console.log('Connected to signer service'); - } catch (error) { - console.error('Failed to connect to signer:', error); - } -})(); - -//------------------------------------ IdNot Protected Endpoints ------------------------------------ - -// Get current user data (protected endpoint) -app.get('/api/v1/idnot/user', authenticateIdNot, async (req: Request, res: Response): Promise => { - console.log('Received request to get user data'); - try { - // Find the full token data which should contain the original idNotUser data - const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); - - if (!userAuth || !userAuth.idNotUser) { - // If we don't have the stored user data, we need to re-fetch it - // This requires decoding the original JWT or re-fetching from IdNot APIs - return res.status(404).json({ - success: false, - message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' - }); - } - - // Return the stored idNotUser data without the authToken - res.json({ - success: true, - data: userAuth.idNotUser - }); - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la récupération des données utilisateur', - error: error.message - }); - } -}); - -// Do we have a process for user office? -app.get('/api/v1/process/user', authenticateIdNot, async (req: Request, res: Response): Promise => { - console.log('Received request to get user process'); - try { - // Find the full token data which should contain the original idNotUser data - const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); - - if (!userAuth || !userAuth.idNotUser) { - // If we don't have the stored user data, we need to re-fetch it - // This requires decoding the original JWT or re-fetching from IdNot APIs - return res.status(404).json({ - success: false, - message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' - }); - } - - const { pairingId } = req.query; - - // Now we ask signer if he knows a process for this office - let process: ProcessInfo | null = await signerClient.getUserProcessByIdnot(userAuth.idNotUser.idNot); - - if (!process) { - console.log('No existing process found in signer, creating a new one'); - // We can use userInfo as data for the process - let uuid: string; - // Try to fetch existing UUID from database by idNot - try { - const result = await Database.query('SELECT uid FROM users WHERE "idNot" = $1', [userAuth.idNotUser.idNot]); - uuid = result.rows.length > 0 ? result.rows[0].uid : null; - } catch (error) { - console.error('Error fetching UUID by idNot:', error); - uuid = ''; - } - - // If no existing UUID found, generate a new one - if (!uuid) { - console.log('No existing UUID found in db, generating a new one'); - uuid = uuidv4(); - } - - const processData: ProcessData = { - uid: uuid, - utype: 'collaborator', - idNot: userAuth.idNotUser.idNot, - office: { - idNot: userAuth.idNotUser.office.idNot, - }, - role: userAuth.idNotUser.role, - office_role: userAuth.idNotUser.office_role, - contact: userAuth.idNotUser.contact, - }; - - console.log('processData', processData); - - const privateFields = Object.keys(processData); - const allFields = [...privateFields, 'roles']; - // Make those fields public - privateFields.splice(privateFields.indexOf('uid'), 1); - privateFields.splice(privateFields.indexOf('utype'), 1); - privateFields.splice(privateFields.indexOf('idNot'), 1); - - const getPairingIdResponse = await signerClient.getPairingId(); - const validatorId = getPairingIdResponse.pairingId; - - const roles: ProcessRoles = { - owner: { - members: [pairingId as string, validatorId], - validation_rules: [ - { - quorum: 0.1, - fields: allFields, - min_sig_member: 1, - } - ], - storages: [DEFAULT_STORAGE] - }, - apophis: { - members: [pairingId as string, validatorId], - validation_rules: [], - storages: [] - } - }; - - const newCollaboratorProcess = await signerClient.createProcess(processData, privateFields, roles); - console.log('Created new process:', newCollaboratorProcess); - // The createProcess returns a ServerResponse, we need to extract the process info - process = { processId: newCollaboratorProcess.processId || '', processData: newCollaboratorProcess.data }; - } else { - console.log('Using process:', process.processId); - } - - // We check that the process is commited, we do it if that's not the case - const allProcesses = await signerClient.getOwnedProcesses(); - if (allProcesses && process) { - const processStates = allProcesses.processes[process.processId].states; - const isNotCommited = processStates.length === 2 - && processStates[1].commited_in === processStates[0].commited_in; - if (isNotCommited) { - console.log('Process is not commited, committing it'); - await signerClient.validateState(process.processId, processStates[0].state_id); - } - - // We check that the pairingId is indeed part of the roles - let roles: ProcessRoles; - if (isNotCommited) { - // We take the first state - const firstState = processStates[0]; - roles = firstState.roles; - } else { - const tip = processStates[processStates.length - 1].commited_in; - const lastState = processStates.findLast((state: any) => state.commited_in !== tip); - roles = lastState.roles; - } - - if (!roles) { - throw new Error('No roles found'); - } else if (!roles['owner']) { - throw new Error('No owner role found'); - } - - if (!roles['owner'].members.includes(req.query.pairingId as string)) { - // We add the new pairingId to the owner role and commit - console.log('Adding new pairingId', req.query.pairingId, 'to owner role'); - roles['owner'].members.push(req.query.pairingId as string); - const updatedProcessReturn = await signerClient.updateProcess(process.processId, {}, [], roles); - console.log('Updated process:', updatedProcessReturn); - const processId = updatedProcessReturn.updatedProcess.process_id; - const stateId = updatedProcessReturn.updatedProcess.diffs[0].state_id; - console.log('processId', processId); - console.log('stateId', stateId); - await signerClient.notifyUpdate(processId, stateId); - await signerClient.validateState(processId, stateId); - } - } else { - throw new Error('No processes found'); - } - - // Return the stored idNotUser data without the authToken - res.json({ - success: true, - data: process - }); - - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la récupération des données utilisateur', - error: error.message - }); - } -}); - -// Do we have a process for user office? -app.get('/api/v1/process/office', authenticateIdNot, async (req: Request, res: Response): Promise => { - console.log('Received request to get office process'); - try { - // Find the full token data which should contain the original idNotUser data - const userAuth = authTokens.find(auth => auth.authToken === req.idNotUser!.authToken); - - if (!userAuth || !userAuth.idNotUser) { - // If we don't have the stored user data, we need to re-fetch it - // This requires decoding the original JWT or re-fetching from IdNot APIs - return res.status(404).json({ - success: false, - message: 'Données utilisateur non trouvées. Veuillez vous reconnecter.' - }); - } - - // If office is not ACTIVATED, we return a 404 error - if (userAuth.idNotUser.office.office_status !== EOfficeStatus.ACTIVATED) { - return res.status(404).json({ - success: false, - message: 'Office not activated' - }); - } - - // Now we ask signer if he knows a process for this office - let process: ProcessInfo | null = await signerClient.getOfficeProcessByIdnot(userAuth.idNotUser.office.idNot); - - if (!process) { - // Let's create it - // We directly use the office info as process data - // For now we create the process only with the validator - const getPairingIdResponse = await signerClient.getPairingId(); - const validatorId = getPairingIdResponse.pairingId; - if (!validatorId) { - return res.status(400).json({ - success: false, - message: 'No validator id found' - }); - } - console.log('validatorId', validatorId); - - const processData: ProcessData = { - uid: uuidv4(), - utype: 'office', - ...userAuth.idNotUser.office, - }; - - const privateFields = Object.keys(processData); - - const roles: ProcessRoles = { - owner: { - members: [validatorId], - validation_rules: [], - storages: [] - }, - apophis: { - members: [validatorId], - validation_rules: [], - storages: [] - } - }; - await signerClient.createProcess(processData, privateFields, roles); - } - - // Return the stored idNotUser data without the authToken - res.json({ - success: true, - data: process - }); - - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la récupération des données utilisateur', - error: error.message - }); - } -}); - -// Logout endpoint (revoke auth token) -app.post('/api/v1/idnot/logout', authenticateIdNot, (req: Request, res: Response) => { - try { - // Remove the auth token from the array - const tokenIndex = authTokens.findIndex(auth => auth.authToken === req.idNotUser!.authToken); - if (tokenIndex > -1) { - authTokens.splice(tokenIndex, 1); - } - - res.json({ - success: true, - message: 'Déconnexion réussie' - }); - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la déconnexion', - error: error.message - }); - } -}); - -// Validate token endpoint (check if token is still valid) -app.get('/api/v1/idnot/validate', authenticateIdNot, (req: Request, res: Response) => { - res.json({ - success: true, - message: 'Token valide', - data: { - idNot: req.idNotUser!.idNot, - valid: true - } - }); -}); - -//------------------------------------ End of Signer Client Integration ------------------------------------ - -/// client auth endpoint -/// client sends its pairing process id, we add it to the customer process -app.post('/api/v1/customer/auth/client-auth', validateSession, async (req: Request, res: Response): Promise => { - const { pairingId } = req.body; - - if (!pairingId) { - return res.status(400).json({ - success: false, - message: 'Missing pairingId' - }); - } - - try { - // This should be implemented properly based on your business logic - // const result = await signerClient.updateProcess(processId, newData, privateFields || [], roles || null); - - // Clean up the session after successful update - SessionManager.deleteSession(req.session!.id); - - res.json({ - success: true, - message: 'Client authentication successful', - data: {} - }); - } catch (error: any) { - console.error('Client authentication error:', error); - res.status(500).json({ - success: false, - message: 'Error during client authentication', - error: error.message - }); - } -}); - -app.post('/api/v1/customer/auth/get-phone-number-for-email', validateSession, async (req: Request, res: Response): Promise => { - const { email } = req.body; - - if (!email) { - return res.status(400).json({ - success: false, - message: 'Missing email' - }); - } - - const phoneNumber = await signerClient.getPhoneNumberForEmail(email); - - if (!phoneNumber) { - return res.status(400).json({ - success: false, - message: 'No phone number found for this email' - }); - } - - res.json({ - success: true, - message: 'Phone number retrieved successfully', - phoneNumber: phoneNumber - }); -}); - -//------------------------------------ Email Section ----------------------------------------- - -const configEmail: EmailConfig = { - MAILCHIMP_API_KEY: process.env.MAILCHIMP_API_KEY, - MAILCHIMP_KEY: process.env.MAILCHIMP_KEY, - MAILCHIMP_LIST_ID: process.env.MAILCHIMP_LIST_ID, - PORT: parseInt(process.env.PORT || '8080'), - FROM_EMAIL: 'no-reply@lecoffre.io', - FROM_NAME: 'LeCoffre.io' -}; - -// Email storage -const pendingEmails = new Map(); - -// Email service -class EmailService { - static async sendTransactionalEmail(to: string, templateName: string, subject: string, templateVariables: Record): Promise<{ success: boolean; result?: any; error?: string }> { - try { - const mailchimpClient = mailchimp(configEmail.MAILCHIMP_API_KEY!); - - const message = { - template_name: templateName, - template_content: [], - message: { - global_merge_vars: this.buildVariables(templateVariables), - from_email: configEmail.FROM_EMAIL, - from_name: configEmail.FROM_NAME, - subject: subject, - to: [ - { - email: to, - type: 'to' - } - ] - } - }; - - const result = await mailchimpClient.messages.sendTemplate(message); - return { success: true, result }; - } catch (error) { - console.error('Erreur envoi email:', error); - return { success: false, error: 'Échec de l\'envoi de l\'email' }; - } - } - - static buildVariables(templateVariables: Record): Array<{ name: string; content: string }> { - return Object.keys(templateVariables).map(key => ({ - name: key, - content: templateVariables[key] - })); - } - - // Add to Mailchimp diffusion list - static async addToMailchimpList(email: string): Promise<{ success: boolean; data?: any; error?: string }> { - try { - const url = `https://us17.api.mailchimp.com/3.0/lists/${configEmail.MAILCHIMP_LIST_ID}/members`; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Authorization': `apikey ${configEmail.MAILCHIMP_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email_address: email, - status: 'subscribed' - }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - return { success: true, data }; - } catch (error) { - console.error('Erreur ajout à la liste:', error); - return { success: false, error: 'Échec de l\'ajout à la liste Mailchimp' }; - } - } - - static async retryFailedEmails(): Promise { - for (const [emailId, emailData] of pendingEmails) { - if (emailData.attempts >= 10) { - pendingEmails.delete(emailId); - continue; - } - - const nextRetryDate = new Date(emailData.lastAttempt); - nextRetryDate.setMinutes(nextRetryDate.getMinutes() + Math.pow(emailData.attempts, 2)); - - if (Date.now() >= nextRetryDate.getTime()) { - try { - const result = await this.sendTransactionalEmail( - emailData.to, - emailData.templateName, - emailData.subject, - emailData.templateVariables - ); - - if (result.success) { - pendingEmails.delete(emailId); - } else { - emailData.attempts += 1; - emailData.lastAttempt = Date.now(); - } - } catch (error) { - emailData.attempts += 1; - emailData.lastAttempt = Date.now(); - } - } - } - } -} - -// Email validation middleware -const validateEmail = (req: Request, res: Response, next: NextFunction): any => { - const { email } = req.body; - - if (!email) { - return res.status(400).json({ - success: false, - message: 'L\'adresse email est requise' - }); - } - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return res.status(400).json({ - success: false, - message: 'Format d\'email invalide' - }); - } - - next(); -}; - -// Routes -app.post('/api/send-email', validateEmail, async (req: Request, res: Response) => { - const { email, firstName, lastName, officeName, template } = req.body; - - try { - const templateVariables = { - first_name: firstName || '', - last_name: lastName || '', - office_name: officeName || '', - link: `${process.env.APP_HOST}` - }; - - const result = await EmailService.sendTransactionalEmail( - email, - ETemplates[template as keyof typeof ETemplates], - 'Votre notaire vous envoie un message', - templateVariables - ); - - if (!result.success) { - // Add to pending emails to retry later - const emailId = `${email}-${Date.now()}`; - pendingEmails.set(emailId, { - to: email, - templateName: ETemplates[template as keyof typeof ETemplates], - subject: 'Votre notaire vous envoie un message', - templateVariables, - attempts: 1, - lastAttempt: Date.now() - }); - } - - res.json({ - success: true, - message: 'Email envoyé avec succès' - }); - } catch (error: any) { - console.error('Erreur:', error); - res.status(500).json({ - success: false, - message: 'Erreur serveur lors de l\'envoi de l\'email' - }); - } -}); - -app.post('/api/subscribe-to-list', validateEmail, async (req: Request, res: Response) => { - const { email } = req.body; - - try { - const result = await EmailService.addToMailchimpList(email); - - if (result.success) { - res.json({ - success: true, - message: 'Inscription à la liste réussie' - }); - } else { - res.status(500).json({ - success: false, - message: 'Échec de l\'inscription à la liste' - }); - } - } catch (error: any) { - console.error('Erreur:', error); - res.status(500).json({ - success: false, - message: 'Erreur serveur lors de l\'inscription' - }); - } -}); - -app.post('/api/send_reminder', async (req: Request, res: Response) => { - const { office, customer } = req.body; - - try { - const to = customer.contact.email; - - const templateVariables = { - office_name: office.name, - last_name: customer.contact.last_name || '', - first_name: customer.contact.first_name || '', - link: `${process.env.APP_HOST}` - }; - - await EmailService.sendTransactionalEmail( - to, - ETemplates.DOCUMENT_REMINDER, - 'Vous avez des documents à déposer pour votre dossier.', - templateVariables - ); - - res.json({ - success: true, - message: 'Email envoyé avec succès' - }); - } catch (error) { - console.error(error); - return; - } -}); - -// Automatic retry system +// Automatic retry system for failed emails setInterval(() => { EmailService.retryFailedEmails(); }, 60000); // Check every minute -//------------------------------------ End of Email Section ------------------------------------ - -//------------------------------------ Stripe Section ------------------------------------------ - -const configStripe: StripeConfig = { - STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, - STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, - APP_HOST: process.env.APP_HOST || 'http://localhost:3000', -}; - -// Stripe service -class StripeService { - private client: Stripe; - private prices: { - STANDARD: { - monthly?: string; - yearly?: string; - }; - UNLIMITED: { - monthly?: string; - yearly?: string; - }; - }; - - constructor() { - this.client = new Stripe(configStripe.STRIPE_SECRET_KEY!); - this.prices = { - STANDARD: { - monthly: process.env.STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID, - yearly: process.env.STRIPE_STANDARD_ANNUAL_SUBSCRIPTION_PRICE_ID - }, - UNLIMITED: { - monthly: process.env.STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID, - yearly: process.env.STRIPE_UNLIMITED_ANNUAL_SUBSCRIPTION_PRICE_ID - } - }; - } - - // Only for test - async createTestSubscription(): Promise<{ - subscriptionId: string; - customerId: string; - status: string; - priceId: string; - }> { - try { - const customer = await this.client.customers.create({ - email: 'test@example.com', - description: 'Client test', - source: 'tok_visa' - }); - - const priceId = this.prices.STANDARD.monthly!; - const price = await this.client.prices.retrieve(priceId); - - const subscription = await this.client.subscriptions.create({ - customer: customer.id, - items: [{ price: price.id }], - payment_behavior: 'default_incomplete', - expand: ['latest_invoice.payment_intent'] - }); - - return { - subscriptionId: subscription.id, - customerId: customer.id, - status: subscription.status, - priceId: price.id - }; - - } catch (error) { - throw error; - } - } - - async createCheckoutSession(subscription: Subscription, frequency: 'monthly' | 'yearly'): Promise { - const priceId = this.getPriceId(subscription.type, frequency); - - return await this.client.checkout.sessions.create({ - mode: 'subscription', - payment_method_types: ['card', 'sepa_debit'], - billing_address_collection: 'auto', - line_items: [{ - price: priceId, - quantity: subscription.type === 'STANDARD' ? subscription.seats || 1 : 1, - }], - success_url: `${configStripe.APP_HOST}/subscription/success`, // Success page (frontend) - cancel_url: `${configStripe.APP_HOST}/subscription/error`, // Error page (frontend) - metadata: { - subscription: JSON.stringify(subscription), - }, - allow_promotion_codes: true, - automatic_tax: { enabled: true } - }); - } - - getPriceId(type: 'STANDARD' | 'UNLIMITED', frequency: 'monthly' | 'yearly'): string { - return this.prices[type][frequency]!; - } - - async getSubscription(subscriptionId: string): Promise { - return await this.client.subscriptions.retrieve(subscriptionId); - } - - async createPortalSession(subscriptionId: string): Promise { - const subscription = await this.getSubscription(subscriptionId); - return await this.client.billingPortal.sessions.create({ - customer: subscription.customer as string, - return_url: `${configStripe.APP_HOST}/subscription/manage` - }); - } -} - -const stripeService = new StripeService(); - -// Validation middleware -const validateSubscription = (req: Request, res: Response, next: NextFunction): any => { - const { type, seats, frequency } = req.body; - - if (!type || !['STANDARD', 'UNLIMITED'].includes(type)) { - return res.status(400).json({ - success: false, - message: 'Type d\'abonnement invalide' - }); - } - - if (type === 'STANDARD' && (!seats || seats < 1)) { - return res.status(400).json({ - success: false, - message: 'Nombre de sièges invalide' - }); - } - - if (!frequency || !['monthly', 'yearly'].includes(frequency)) { - return res.status(400).json({ - success: false, - message: 'Fréquence invalide' - }); - } - - next(); -}; - -// Routes - -// Only for test -app.post('/api/test/create-subscription', async (req: Request, res: Response) => { - try { - const result = await stripeService.createTestSubscription(); - res.json({ - success: true, - data: result - }); - } catch (error: any) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la création de l\'abonnement de test', - error: { - message: error.message, - type: error.type, - code: error.code - } - }); - } -}); - -app.post('/api/subscriptions/checkout', validateSubscription, async (req: Request, res: Response) => { - try { - const session = await stripeService.createCheckoutSession(req.body, req.body.frequency); - res.json({ success: true, sessionId: session.id }); - } catch (error) { - console.error('Error creating checkout:', error); - res.status(500).json({ - success: false, - message: 'Erreur lors de la création de la session de paiement' - }); - } -}); - -app.get('/api/subscriptions/:id', async (req: Request, res: Response) => { - try { - const subscription = await stripeService.getSubscription(req.params.id); - res.json({ success: true, subscription }); - } catch (error) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la récupération de l\'abonnement' - }); - } -}); - -app.post('/api/subscriptions/:id/portal', async (req: Request, res: Response) => { - try { - const session = await stripeService.createPortalSession(req.params.id); - res.json({ success: true, url: session.url }); - } catch (error) { - res.status(500).json({ - success: false, - message: 'Erreur lors de la création de la session du portail' - }); - } -}); - -// Webhook Stripe -app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req: Request, res: Response): Promise => { - const sig = req.headers['stripe-signature'] as string; - let event: Stripe.Event; - - try { - event = Stripe.webhooks.constructEvent(req.body, sig, configStripe.STRIPE_WEBHOOK_SECRET!); - } catch (err: any) { - return res.status(400).send(`Webhook Error: ${err.message}`); - } - - try { - switch (event.type) { - case 'checkout.session.completed': - const session = event.data.object as Stripe.Checkout.Session; - if (session.status === 'complete') { - const subscription = JSON.parse(session.metadata!.subscription); - // Stock subscription (create process) - console.log('New subscription:', subscription); - } - break; - - case 'invoice.payment_succeeded': - const invoice = event.data.object as Stripe.Invoice; - if (['subscription_update', 'subscription_cycle'].includes(invoice.billing_reason!)) { - const subscription = await stripeService.getSubscription((invoice as any).subscription); - // Update subscription (update process) - console.log('Subscription update:', subscription); - } - break; - - case 'customer.subscription.deleted': - const deletedSubscription = event.data.object as Stripe.Subscription; - // Delete subscription (update process to delete) - console.log('Subscription deleted:', deletedSubscription.id); - break; - } - - res.json({ received: true }); - } catch (error) { - console.error('Webhook error:', error); - res.status(500).json({ - success: false, - message: 'Error processing webhook' - }); - } -}); - -//------------------------------------ End of Stripe Section ----------------------------------- - // Initialisation et démarrage du serveur async function startServer(): Promise { try { @@ -1679,5 +119,36 @@ async function startServer(): Promise { } } +// Graceful shutdown handling +process.on('SIGTERM', () => { + Logger.info('SIGTERM received, shutting down gracefully'); + SignerImprovedService.cleanup(); + process.exit(0); +}); + +process.on('SIGINT', () => { + Logger.info('SIGINT received, shutting down gracefully'); + SignerImprovedService.cleanup(); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error) => { + Logger.error('Uncaught exception', { + error: error.message, + stack: error.stack + }); + SignerImprovedService.cleanup(); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + Logger.error('Unhandled promise rejection', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined + }); +}); + // Démarrage de l'application -startServer(); +startServer(); \ No newline at end of file diff --git a/src/services/email/index.ts b/src/services/email/index.ts new file mode 100644 index 0000000..11d41cd --- /dev/null +++ b/src/services/email/index.ts @@ -0,0 +1,107 @@ +import mailchimp = require('@mailchimp/mailchimp_transactional'); +import fetch from 'node-fetch'; +import { emailConfig } from '../../config/email'; +import { PendingEmail } from '../../types'; + +// Email storage +export const pendingEmails = new Map(); + +export class EmailService { + static async sendTransactionalEmail(to: string, templateName: string, subject: string, templateVariables: Record): Promise<{ success: boolean; result?: any; error?: string }> { + try { + const mailchimpClient = mailchimp(emailConfig.MAILCHIMP_API_KEY!); + + const message = { + template_name: templateName, + template_content: [], + message: { + global_merge_vars: this.buildVariables(templateVariables), + from_email: emailConfig.FROM_EMAIL, + from_name: emailConfig.FROM_NAME, + subject: subject, + to: [ + { + email: to, + type: 'to' + } + ] + } + }; + + const result = await mailchimpClient.messages.sendTemplate(message); + return { success: true, result }; + } catch (error) { + console.error('Erreur envoi email:', error); + return { success: false, error: 'Échec de l\'envoi de l\'email' }; + } + } + + static buildVariables(templateVariables: Record): Array<{ name: string; content: string }> { + return Object.keys(templateVariables).map(key => ({ + name: key, + content: templateVariables[key] + })); + } + + // Add to Mailchimp diffusion list + static async addToMailchimpList(email: string): Promise<{ success: boolean; data?: any; error?: string }> { + try { + const url = `https://us17.api.mailchimp.com/3.0/lists/${emailConfig.MAILCHIMP_LIST_ID}/members`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `apikey ${emailConfig.MAILCHIMP_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email_address: email, + status: 'subscribed' + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return { success: true, data }; + } catch (error) { + console.error('Erreur ajout à la liste:', error); + return { success: false, error: 'Échec de l\'ajout à la liste Mailchimp' }; + } + } + + static async retryFailedEmails(): Promise { + for (const [emailId, emailData] of pendingEmails) { + if (emailData.attempts >= 10) { + pendingEmails.delete(emailId); + continue; + } + + const nextRetryDate = new Date(emailData.lastAttempt); + nextRetryDate.setMinutes(nextRetryDate.getMinutes() + Math.pow(emailData.attempts, 2)); + + if (Date.now() >= nextRetryDate.getTime()) { + try { + const result = await this.sendTransactionalEmail( + emailData.to, + emailData.templateName, + emailData.subject, + emailData.templateVariables + ); + + if (result.success) { + pendingEmails.delete(emailId); + } else { + emailData.attempts += 1; + emailData.lastAttempt = Date.now(); + } + } catch (error) { + emailData.attempts += 1; + emailData.lastAttempt = Date.now(); + } + } + } + } +} diff --git a/src/services/idnot/index.ts b/src/services/idnot/index.ts new file mode 100644 index 0000000..6218405 --- /dev/null +++ b/src/services/idnot/index.ts @@ -0,0 +1,159 @@ +import fetch from 'node-fetch'; +import { idnotConfig } from '../../config/idnot'; +import { IdNotUser, ECivility, EOfficeStatus, EIdnotRole } from '../../types'; + +export class IdNotService { + static async exchangeCodeForTokens(code: string) { + const params = { + client_id: idnotConfig.CLIENT_ID, + client_secret: idnotConfig.CLIENT_SECRET, + redirect_uri: idnotConfig.REDIRECT_URI, + grant_type: 'authorization_code', + code: code + }; + + const tokens = await ( + await fetch(idnotConfig.TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(params).toString() + }) + ).json(); + + return tokens; + } + + static async getUserRattachements(idNot: string) { + const searchParams = new URLSearchParams({ + key: idnotConfig.IDNOT_API_KEY || '', + deleted: 'false' + }); + + const url = `${idnotConfig.IDNOT_ANNUARY_BASE_URL}/api/pp/v2/personnes/${idNot}/rattachements?` + searchParams; + + const json = await ( + await fetch(url, { + method: 'GET' + }) + ).json(); + + return json; + } + + static async getOfficeRattachements(idNot: string) { + const searchParams = new URLSearchParams({ + key: idnotConfig.IDNOT_API_KEY || '', + deleted: 'false' + }); + + const url = `${idnotConfig.IDNOT_ANNUARY_BASE_URL}/api/pp/v2/entites/${idNot}/personnes?` + searchParams; + + const json = await ( + await fetch(url, { + method: 'GET' + }) + ).json(); + + return json; + } + + static async getUserData(profileIdn: string) { + const searchParams = new URLSearchParams({ + key: idnotConfig.IDNOT_API_KEY || '' + }); + + const userData = await ( + await fetch(`${idnotConfig.API_BASE_URL}/api/pp/v2/rattachements/${profileIdn}?` + searchParams, { + method: 'GET' + }) + ).json(); + + return userData; + } + + static async getOfficeLocationData(locationsUrl: string) { + const searchParams = new URLSearchParams({ + key: idnotConfig.IDNOT_API_KEY || '' + }); + + const officeLocationData = await ( + await fetch(`${idnotConfig.API_BASE_URL}${locationsUrl}?` + searchParams, { + method: 'GET' + }) + ).json(); + + return officeLocationData; + } + + static getOfficeStatus(statusName: string): EOfficeStatus { + switch (statusName) { + case "Pourvu": + return EOfficeStatus.ACTIVATED; + case "Pourvu mais décédé": + return EOfficeStatus.ACTIVATED; + case "Sans titulaire": + return EOfficeStatus.ACTIVATED; + case "Vacance": + return EOfficeStatus.ACTIVATED; + case "En activité": + return EOfficeStatus.ACTIVATED; + default: + return EOfficeStatus.DESACTIVATED; + } + } + + static getOfficeRole(roleName: string): { name: string } | null { + switch (roleName) { + case EIdnotRole.NOTAIRE_TITULAIRE: + return { name: 'Notaire' }; + case EIdnotRole.NOTAIRE_ASSOCIE: + return { name: 'Notaire' }; + case EIdnotRole.NOTAIRE_SALARIE: + return { name: 'Notaire' }; + case EIdnotRole.COLLABORATEUR: + return { name: 'Collaborateur' }; + case EIdnotRole.SUPPLEANT: + return { name: 'Collaborateur' }; + case EIdnotRole.ADMINISTRATEUR: + return { name: 'Collaborateur' }; + case EIdnotRole.CURATEUR: + return { name: 'Collaborateur' }; + default: + return null; + } + } + + static getRole(roleName: string): { name: string } { + switch (roleName) { + case EIdnotRole.NOTAIRE_TITULAIRE: + return { name: 'admin' }; + case EIdnotRole.NOTAIRE_ASSOCIE: + return { name: 'admin' }; + case EIdnotRole.NOTAIRE_SALARIE: + return { name: 'notary' }; + case EIdnotRole.COLLABORATEUR: + return { name: 'notary' }; + case EIdnotRole.SUPPLEANT: + return { name: 'notary' }; + case EIdnotRole.ADMINISTRATEUR: + return { name: 'admin' }; + case EIdnotRole.CURATEUR: + return { name: 'notary' }; + default: + return { name: 'default' }; + } + } + + static getCivility(civility: string): ECivility { + switch (civility) { + case 'Monsieur': + return ECivility.MALE; + case 'Madame': + return ECivility.FEMALE; + default: + return ECivility.OTHERS; + } + } +} diff --git a/src/services/signer-improved/index.ts b/src/services/signer-improved/index.ts new file mode 100644 index 0000000..0ae44c7 --- /dev/null +++ b/src/services/signer-improved/index.ts @@ -0,0 +1,482 @@ +import { SDKSignerClient, ClientConfig } from 'sdk-signer-client'; +import { signerConfig } from '../../config/signer'; +import { Logger } from '../../utils/logger'; +import { Result, ServiceResult } from '../../utils/result'; + +export enum SignerConnectionState { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + FAILED = 'failed' +} + +export interface SignerHealthCheck { + state: SignerConnectionState; + lastConnected?: number; + lastError?: string; + reconnectAttempts: number; + nextReconnectAt?: number; +} + +export class SignerImprovedService { + private static instance: SDKSignerClient | null = null; + private static connectionState: SignerConnectionState = SignerConnectionState.DISCONNECTED; + private static reconnectAttempts: number = 0; + private static lastConnected: number | null = null; + private static lastError: string | null = null; + private static reconnectTimeout: NodeJS.Timeout | null = null; + private static maxReconnectAttempts: number = 10; // More attempts than the SDK default + private static reconnectInterval: number = 5000; // 5 seconds + private static backoffMultiplier: number = 1.5; // Exponential backoff + private static maxReconnectInterval: number = 60000; // Max 60 seconds between attempts + private static healthCheckInterval: NodeJS.Timeout | null = null; + private static connectionCallbacks: Array<(connected: boolean) => void> = []; + + /** + * Initialize the signer service with enhanced reconnection logic + */ + static async initialize(): Promise> { + try { + Logger.info('Initializing Signer service'); + + // Create client instance + this.instance = new SDKSignerClient(signerConfig); + + // Set up event listeners for connection monitoring + this.setupEventListeners(); + + // Start periodic health checks + this.startHealthChecks(); + + // Attempt initial connection + const connectResult = await this.connect(); + + return connectResult; + } catch (error) { + Logger.error('Failed to initialize Signer service', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + return Result.fromError(error instanceof Error ? error : new Error('Initialization failed'), 'SIGNER_INIT_ERROR'); + } + } + + /** + * Get the signer client instance with connection validation + */ + static async getInstance(): Promise> { + if (!this.instance) { + const initResult = await this.initialize(); + if (!initResult.success) { + return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer service not initialized', initResult.error); + } + } + + // Check if we're connected + if (this.connectionState !== SignerConnectionState.CONNECTED) { + // Try to reconnect if not already attempting + if (this.connectionState !== SignerConnectionState.CONNECTING && + this.connectionState !== SignerConnectionState.RECONNECTING) { + Logger.warn('Signer not connected, attempting reconnection'); + const reconnectResult = await this.connect(); + if (!reconnectResult.success) { + return Result.failure('SIGNER_CONNECTION_FAILED', 'Failed to connect to signer', reconnectResult.error); + } + } else { + return Result.failure('SIGNER_CONNECTING', 'Signer is currently connecting, please retry'); + } + } + + return Result.success(this.instance!); + } + + /** + * Connect to the signer service with retry logic + */ + private static async connect(): Promise> { + if (!this.instance) { + return Result.failure('SIGNER_NOT_INITIALIZED', 'Signer client not initialized'); + } + + this.connectionState = this.reconnectAttempts === 0 ? SignerConnectionState.CONNECTING : SignerConnectionState.RECONNECTING; + + try { + // Only log connection attempts if we've been trying for a while + if (this.reconnectAttempts > 2) { + Logger.info('Signer connection attempt', { + attempt: this.reconnectAttempts + 1, + maxAttempts: this.maxReconnectAttempts + }); + } + + await this.instance.connect(); + + // Connection successful - state will be updated by the 'open' event handler + return Result.success(undefined); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'; + this.lastError = errorMessage; + + // Only log detailed errors after several attempts or if we've reached the limit + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.connectionState = SignerConnectionState.FAILED; + Logger.error('Signer connection failed after all attempts', { + attempts: this.maxReconnectAttempts, + lastError: errorMessage + }); + return Result.failure('SIGNER_CONNECTION_FAILED', `Failed to connect after ${this.maxReconnectAttempts} attempts: ${errorMessage}`); + } else if (this.reconnectAttempts > 3) { + Logger.warn('Signer connection failing', { + attempt: this.reconnectAttempts + 1, + error: errorMessage + }); + } + + // Schedule reconnection with exponential backoff + this.scheduleReconnection(); + + return Result.failure('SIGNER_CONNECTION_FAILED', errorMessage); + } + } + + /** + * Schedule a reconnection attempt with exponential backoff + */ + private static scheduleReconnection(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + // Calculate delay with exponential backoff + const baseDelay = this.reconnectInterval; + const delay = Math.min( + baseDelay * Math.pow(this.backoffMultiplier, this.reconnectAttempts), + this.maxReconnectInterval + ); + + this.reconnectAttempts++; + + // Only log scheduling if this is taking a while + if (this.reconnectAttempts > 2) { + Logger.debug('Next reconnection in', { delayMs: delay }); + } + + this.reconnectTimeout = setTimeout(async () => { + await this.connect(); + }, delay); + } + + /** + * Set up event listeners for the signer client + */ + private static setupEventListeners(): void { + if (!this.instance) return; + + try { + // Listen for WebSocket close events + this.instance.on('close', () => { + // Don't log here - let handleDisconnection() do the logging + this.handleDisconnection(); + }); + + // Listen for WebSocket error events + this.instance.on('error', (error: Error) => { + // Only log if it's a new error + if (this.lastError !== error.message) { + Logger.error('Signer WebSocket error', { error: error.message }); + } + this.handleError(error); + }); + + // Listen for successful reconnection events + this.instance.on('reconnect', () => { + Logger.info('Signer reconnected via SDK'); + this.handleSDKReconnection(); + }); + + // Listen for connection open events + this.instance.on('open', () => { + Logger.info('Signer connected'); + this.handleSDKConnection(); + }); + + Logger.debug('Signer event listeners configured'); + + } catch (error) { + Logger.warn('Could not set up SDK event listeners', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + /** + * Handle disconnection events from WebSocket + */ + private static handleDisconnection(): void { + // Only handle if we were actually connected (avoid duplicate handling) + if (this.connectionState === SignerConnectionState.CONNECTED) { + Logger.warn('Signer disconnected - reconnecting...'); + this.connectionState = SignerConnectionState.DISCONNECTED; + this.notifyConnectionCallbacks(false); + + // Cancel any pending health checks since we know we're disconnected + this.cancelScheduledOperations(); + + // Initiate immediate reconnection (but respect ongoing attempts) + if (!this.reconnectTimeout) { + this.scheduleReconnection(); + } + } + } + + /** + * Handle connection errors from WebSocket + */ + private static handleError(error: Error): void { + this.lastError = error.message; + + // Only log detailed error if we were connected (avoid startup error spam) + if (this.connectionState === SignerConnectionState.CONNECTED) { + Logger.error('Signer connection error', { error: error.message }); + this.handleDisconnection(); + } + } + + /** + * Handle successful SDK reconnection + */ + private static handleSDKReconnection(): void { + // The SDK handled the reconnection, update our state + this.connectionState = SignerConnectionState.CONNECTED; + this.lastConnected = Date.now(); + this.lastError = null; + this.reconnectAttempts = 0; + + // Clear any pending reconnection timeout since SDK handled it + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + // Notify callbacks (they will handle the logging) + this.notifyConnectionCallbacks(true); + } + + /** + * Handle SDK connection open events + */ + private static handleSDKConnection(): void { + this.connectionState = SignerConnectionState.CONNECTED; + this.lastConnected = Date.now(); + this.lastError = null; + this.reconnectAttempts = 0; + + // Clear any pending operations + this.cancelScheduledOperations(); + + // Notify callbacks (they will handle the logging) + this.notifyConnectionCallbacks(true); + } + + /** + * Cancel scheduled operations (reconnection, health checks during known disconnection) + */ + private static cancelScheduledOperations(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + } + + /** + * Start periodic health checks + */ + private static startHealthChecks(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + + this.healthCheckInterval = setInterval(async () => { + await this.performHealthCheck(); + }, 30000); // Check every 30 seconds + } + + /** + * Perform a health check on the signer connection + */ + private static async performHealthCheck(): Promise { + // Only perform health checks if we think we're connected + if (!this.instance || this.connectionState !== SignerConnectionState.CONNECTED) { + Logger.debug('Skipping health check - not in connected state', { + state: this.connectionState, + hasInstance: !!this.instance + }); + return; + } + + try { + // Try a simple operation to verify connection is actually working + await this.instance.getPairingId(); + + Logger.debug('Signer health check passed'); + + } catch (error) { + Logger.warn('Signer health check failed - connection may be stale', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + + // Health check failed, but we haven't received a close/error event + // This indicates a stale connection - treat as disconnection + this.handleDisconnection(); + } + } + + /** + * Execute a signer operation with automatic retry on connection failure + */ + static async executeWithRetry( + operation: (client: SDKSignerClient) => Promise, + operationName: string, + maxRetries: number = 3 + ): Promise> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const clientResult = await this.getInstance(); + if (!clientResult.success) { + lastError = new Error(clientResult.error?.message || 'Failed to get signer instance'); + + if (attempt < maxRetries) { + // Only log operation retries if it's the final attempt or taking too long + if (attempt === maxRetries - 1) { + Logger.warn(`${operationName} final retry attempt`, { + error: lastError.message + }); + } + + // Wait before retry + await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); + continue; + } + break; + } + + const result = await operation(clientResult.data!); + + // Only log success if it took multiple attempts + if (attempt > 1) { + Logger.info(`${operationName} succeeded after ${attempt} attempts`); + } + + return Result.success(result); + + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + + // Only log operation failures if it's taking multiple attempts + if (attempt > 1) { + Logger.warn(`${operationName} attempt ${attempt} failed`, { + error: lastError.message + }); + } + + // If it's a connection error, force reconnection + if (lastError.message.includes('connection') || lastError.message.includes('websocket')) { + this.connectionState = SignerConnectionState.DISCONNECTED; + } + + if (attempt < maxRetries) { + // Wait before retry with exponential backoff + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1))); + } + } + } + + Logger.error(`${operationName} failed after ${maxRetries} attempts`, { + error: lastError?.message || 'Unknown error' + }); + + return Result.fromError(lastError || new Error('Operation failed'), 'SIGNER_OPERATION_FAILED'); + } + + /** + * Get connection health status + */ + static getHealthStatus(): SignerHealthCheck { + return { + state: this.connectionState, + lastConnected: this.lastConnected || undefined, + lastError: this.lastError || undefined, + reconnectAttempts: this.reconnectAttempts, + nextReconnectAt: this.reconnectTimeout ? Date.now() + this.reconnectInterval : undefined + }; + } + + /** + * Force reconnection (useful for manual recovery) + */ + static async forceReconnect(): Promise> { + Logger.info('Force reconnection requested'); + + // Reset state + this.connectionState = SignerConnectionState.DISCONNECTED; + this.reconnectAttempts = 0; + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + return await this.connect(); + } + + /** + * Register a callback for connection state changes + */ + static onConnectionChange(callback: (connected: boolean) => void): () => void { + this.connectionCallbacks.push(callback); + + // Return unsubscribe function + return () => { + const index = this.connectionCallbacks.indexOf(callback); + if (index > -1) { + this.connectionCallbacks.splice(index, 1); + } + }; + } + + /** + * Notify all connection callbacks + */ + private static notifyConnectionCallbacks(connected: boolean): void { + this.connectionCallbacks.forEach(callback => { + try { + callback(connected); + } catch (error) { + Logger.error('Error in connection callback', { + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + }); + } + + /** + * Cleanup resources + */ + static cleanup(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + + this.connectionCallbacks = []; + this.connectionState = SignerConnectionState.DISCONNECTED; + } +} diff --git a/src/services/signer/index.ts b/src/services/signer/index.ts new file mode 100644 index 0000000..393456d --- /dev/null +++ b/src/services/signer/index.ts @@ -0,0 +1,18 @@ +import { SDKSignerClient } from 'sdk-signer-client'; +import { signerConfig } from '../../config/signer'; + +export class SignerService { + private static instance: SDKSignerClient; + + static getInstance(): SDKSignerClient { + if (!this.instance) { + this.instance = new SDKSignerClient(signerConfig); + } + return this.instance; + } + + static async connect(): Promise { + const client = this.getInstance(); + await client.connect(); + } +} diff --git a/src/services/sms-improved/index.ts b/src/services/sms-improved/index.ts new file mode 100644 index 0000000..6f330b9 --- /dev/null +++ b/src/services/sms-improved/index.ts @@ -0,0 +1,170 @@ +import ovh = require('ovh'); +import fetch from 'node-fetch'; +import { smsConfig } from '../../config/sms'; +import { Result, ServiceResult } from '../../utils/result'; +import { Logger } from '../../utils/logger'; + +export interface SmsResult { + messageId?: string; + provider: 'ovh' | 'smsfactor'; +} + +export class SmsImprovedService { + static generateCode(): number { + return Math.floor(100000 + Math.random() * 900000); + } + + // OVH Service with proper error handling + private static async sendSmsWithOvh(phoneNumber: string, message: string): Promise> { + return new Promise((resolve) => { + try { + const ovhClient = ovh({ + appKey: smsConfig.OVH_APP_KEY!, + appSecret: smsConfig.OVH_APP_SECRET!, + consumerKey: smsConfig.OVH_CONSUMER_KEY! + }); + + ovhClient.request('POST', `/sms/${smsConfig.OVH_SMS_SERVICE_NAME}/jobs`, { + message: message, + receivers: [phoneNumber], + senderForResponse: false, + sender: 'not.IT Fact', + noStopClause: true + }, (error: any, result: any) => { + if (error) { + Logger.logServiceOperation('SMS', 'sendSmsWithOvh', false, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + error: error.message + }); + + resolve(Result.failure('OVH_SMS_ERROR', `Erreur OVH SMS: ${error.message}`, { + provider: 'ovh', + originalError: error + })); + } else { + Logger.logServiceOperation('SMS', 'sendSmsWithOvh', true, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + messageId: result?.ids?.[0] + }); + + resolve(Result.success({ + messageId: result?.ids?.[0], + provider: 'ovh' + })); + } + }); + } catch (error) { + Logger.logServiceOperation('SMS', 'sendSmsWithOvh', false, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + error: error instanceof Error ? error.message : 'Unknown error' + }); + + resolve(Result.fromError(error instanceof Error ? error : new Error('Unknown OVH error'), 'OVH_SMS_ERROR')); + } + }); + } + + // SMS Factor Service with proper error handling + private static async sendSmsWithSmsFactor(phoneNumber: string, message: string): Promise> { + try { + const url = new URL('https://api.smsfactor.com/send/simulate'); + url.searchParams.append('to', phoneNumber); + url.searchParams.append('text', message); + url.searchParams.append('sender', 'LeCoffre'); + url.searchParams.append('token', smsConfig.SMS_FACTOR_TOKEN!); + + const response = await fetch(url.toString()); + + if (!response.ok) { + const errorText = await response.text(); + Logger.logServiceOperation('SMS', 'sendSmsWithSmsFactor', false, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + error: `HTTP ${response.status}: ${errorText}` + }); + + return Result.failure('SMSFACTOR_SMS_ERROR', `Erreur SMS Factor: HTTP ${response.status}`, { + provider: 'smsfactor', + statusCode: response.status, + response: errorText + }); + } + + const result = await response.json(); + + Logger.logServiceOperation('SMS', 'sendSmsWithSmsFactor', true, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + messageId: result?.id + }); + + return Result.success({ + messageId: result?.id, + provider: 'smsfactor' + }); + + } catch (error) { + Logger.logServiceOperation('SMS', 'sendSmsWithSmsFactor', false, { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + error: error instanceof Error ? error.message : 'Unknown error' + }); + + return Result.fromError( + error instanceof Error ? error : new Error('Unknown SMS Factor error'), + 'SMSFACTOR_SMS_ERROR' + ); + } + } + + // Main method with fallback and proper error aggregation + static async sendSms(phoneNumber: string, message: string): Promise> { + Logger.info('Initiating SMS send', { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + messageLength: message.length + }); + + // Try OVH first + const ovhResult = await this.sendSmsWithOvh(phoneNumber, message); + + if (ovhResult.success) { + return ovhResult; + } + + // If OVH fails, try SMS Factor + Logger.info('OVH SMS failed, trying SMS Factor fallback', { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + ovhError: ovhResult.error?.message + }); + + const smsFactorResult = await this.sendSmsWithSmsFactor(phoneNumber, message); + + if (smsFactorResult.success) { + return smsFactorResult; + } + + // Both services failed + Logger.error('All SMS providers failed', { + phoneNumber: phoneNumber.replace(/\d(?=\d{4})/g, '*'), + ovhError: ovhResult.error?.message, + smsFactorError: smsFactorResult.error?.message + }); + + return Result.failure('ALL_SMS_PROVIDERS_FAILED', 'Échec de l\'envoi du SMS via tous les fournisseurs', { + providers: { + ovh: ovhResult.error, + smsfactor: smsFactorResult.error + } + }); + } + + // Health check method + static async healthCheck(): Promise> { + const checks = await Promise.allSettled([ + this.sendSmsWithOvh('+33123456789', 'Health check'), + this.sendSmsWithSmsFactor('+33123456789', 'Health check') + ]); + + return Result.success({ + ovh: checks[0].status === 'fulfilled' && checks[0].value.success, + smsfactor: checks[1].status === 'fulfilled' && checks[1].value.success + }); + } +} diff --git a/src/services/sms/index.ts b/src/services/sms/index.ts new file mode 100644 index 0000000..73c475b --- /dev/null +++ b/src/services/sms/index.ts @@ -0,0 +1,71 @@ +import ovh = require('ovh'); +import fetch from 'node-fetch'; +import { smsConfig } from '../../config/sms'; + +export class SmsService { + static generateCode(): number { + return Math.floor(100000 + Math.random() * 900000); + } + + // OVH Service + static sendSmsWithOvh(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { + return new Promise((resolve, reject) => { + const ovhClient = ovh({ + appKey: smsConfig.OVH_APP_KEY!, + appSecret: smsConfig.OVH_APP_SECRET!, + consumerKey: smsConfig.OVH_CONSUMER_KEY! + }); + + ovhClient.request('POST', `/sms/${smsConfig.OVH_SMS_SERVICE_NAME}/jobs`, { + message: message, + receivers: [phoneNumber], + senderForResponse: false, + sender: 'not.IT Fact', + noStopClause: true + }, (error: any, result: any) => { + if (error) { + console.error('Erreur OVH SMS:', error); + resolve({ success: false, error: 'Échec de l\'envoi du SMS via OVH' }); + } else { + resolve({ success: true }); + } + }); + }); + } + + // SMS Factor Service + static async sendSmsWithSmsFactor(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { + try { + const url = new URL('https://api.smsfactor.com/send/simulate'); + url.searchParams.append('to', phoneNumber); + url.searchParams.append('text', message); + url.searchParams.append('sender', 'LeCoffre'); + url.searchParams.append('token', smsConfig.SMS_FACTOR_TOKEN!); + + const response = await fetch(url.toString()); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return { success: true }; + } catch (error) { + console.error('Erreur SMS Factor:', error); + return { success: false, error: 'Échec de l\'envoi du SMS via SMS Factor' }; + } + } + + // Main method + static async sendSms(phoneNumber: string, message: string): Promise<{ success: boolean; error?: string }> { + // Try first with OVH + const ovhResult = await this.sendSmsWithOvh(phoneNumber, message); + + if (ovhResult.success) { + return ovhResult; + } + + // If OVH fails, try with SMS Factor + console.log('OVH SMS failed, trying SMS Factor...'); + return await this.sendSmsWithSmsFactor(phoneNumber, message); + } +} diff --git a/src/services/stripe/index.ts b/src/services/stripe/index.ts new file mode 100644 index 0000000..d7937d0 --- /dev/null +++ b/src/services/stripe/index.ts @@ -0,0 +1,108 @@ +import Stripe from 'stripe'; +import { stripeConfig } from '../../config/stripe'; +import { Subscription } from '../../types'; + +export class StripeService { + private client: Stripe; + private prices: { + STANDARD: { + monthly?: string; + yearly?: string; + }; + UNLIMITED: { + monthly?: string; + yearly?: string; + }; + }; + + constructor() { + this.client = new Stripe(stripeConfig.STRIPE_SECRET_KEY!); + this.prices = { + STANDARD: { + monthly: process.env.STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID, + yearly: process.env.STRIPE_STANDARD_ANNUAL_SUBSCRIPTION_PRICE_ID + }, + UNLIMITED: { + monthly: process.env.STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID, + yearly: process.env.STRIPE_UNLIMITED_ANNUAL_SUBSCRIPTION_PRICE_ID + } + }; + } + + // Only for test + async createTestSubscription(): Promise<{ + subscriptionId: string; + customerId: string; + status: string; + priceId: string; + }> { + try { + const customer = await this.client.customers.create({ + email: 'test@example.com', + description: 'Client test', + source: 'tok_visa' + }); + + const priceId = this.prices.STANDARD.monthly!; + const price = await this.client.prices.retrieve(priceId); + + const subscription = await this.client.subscriptions.create({ + customer: customer.id, + items: [{ price: price.id }], + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'] + }); + + return { + subscriptionId: subscription.id, + customerId: customer.id, + status: subscription.status, + priceId: price.id + }; + + } catch (error) { + throw error; + } + } + + async createCheckoutSession(subscription: Subscription, frequency: 'monthly' | 'yearly'): Promise { + const priceId = this.getPriceId(subscription.type, frequency); + + return await this.client.checkout.sessions.create({ + mode: 'subscription', + payment_method_types: ['card', 'sepa_debit'], + billing_address_collection: 'auto', + line_items: [{ + price: priceId, + quantity: subscription.type === 'STANDARD' ? subscription.seats || 1 : 1, + }], + success_url: `${stripeConfig.APP_HOST}/subscription/success`, // Success page (frontend) + cancel_url: `${stripeConfig.APP_HOST}/subscription/error`, // Error page (frontend) + metadata: { + subscription: JSON.stringify(subscription), + }, + allow_promotion_codes: true, + automatic_tax: { enabled: true } + }); + } + + getPriceId(type: 'STANDARD' | 'UNLIMITED', frequency: 'monthly' | 'yearly'): string { + return this.prices[type][frequency]!; + } + + async getSubscription(subscriptionId: string): Promise { + return await this.client.subscriptions.retrieve(subscriptionId); + } + + async createPortalSession(subscriptionId: string): Promise { + const subscription = await this.getSubscription(subscriptionId); + return await this.client.billingPortal.sessions.create({ + customer: subscription.customer as string, + return_url: `${stripeConfig.APP_HOST}/subscription/manage` + }); + } + + getClient(): Stripe { + return this.client; + } +} diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..ac1df8f --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,128 @@ +export enum ErrorCode { + // Validation errors (4xx) + VALIDATION_ERROR = 'VALIDATION_ERROR', + MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD', + INVALID_FORMAT = 'INVALID_FORMAT', + UNAUTHORIZED = 'UNAUTHORIZED', + FORBIDDEN = 'FORBIDDEN', + NOT_FOUND = 'NOT_FOUND', + RATE_LIMITED = 'RATE_LIMITED', + + // Business logic errors (4xx) + BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION', + EXPIRED_TOKEN = 'EXPIRED_TOKEN', + EXPIRED_CODE = 'EXPIRED_CODE', + INVALID_CODE = 'INVALID_CODE', + TOO_MANY_ATTEMPTS = 'TOO_MANY_ATTEMPTS', + + // External service errors (5xx) + EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR', + SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR', + EMAIL_SERVICE_ERROR = 'EMAIL_SERVICE_ERROR', + STRIPE_SERVICE_ERROR = 'STRIPE_SERVICE_ERROR', + IDNOT_SERVICE_ERROR = 'IDNOT_SERVICE_ERROR', + SIGNER_SERVICE_ERROR = 'SIGNER_SERVICE_ERROR', + + // System errors (5xx) + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + DATABASE_ERROR = 'DATABASE_ERROR', + CONFIGURATION_ERROR = 'CONFIGURATION_ERROR', + NETWORK_ERROR = 'NETWORK_ERROR' +} + +export interface ErrorDetails { + field?: string; + code?: string; + value?: any; + constraints?: string[]; +} + +export class AppError extends Error { + public readonly code: ErrorCode; + public readonly statusCode: number; + public readonly isOperational: boolean; + public readonly details?: ErrorDetails[]; + public readonly requestId?: string; + public readonly timestamp: string; + + constructor( + code: ErrorCode, + message: string, + statusCode: number = 500, + isOperational: boolean = true, + details?: ErrorDetails[], + requestId?: string + ) { + super(message); + + this.code = code; + this.statusCode = statusCode; + this.isOperational = isOperational; + this.details = details; + this.requestId = requestId; + this.timestamp = new Date().toISOString(); + + // Ensure proper prototype chain + Object.setPrototypeOf(this, AppError.prototype); + + // Capture stack trace + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + success: false, + error: { + code: this.code, + message: this.message, + details: this.details, + timestamp: this.timestamp, + requestId: this.requestId + } + }; + } +} + +// Predefined error classes for common scenarios +export class ValidationError extends AppError { + constructor(message: string, details?: ErrorDetails[], requestId?: string) { + super(ErrorCode.VALIDATION_ERROR, message, 400, true, details, requestId); + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'Token d\'authentification requis', requestId?: string) { + super(ErrorCode.UNAUTHORIZED, message, 401, true, undefined, requestId); + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Accès interdit', requestId?: string) { + super(ErrorCode.FORBIDDEN, message, 403, true, undefined, requestId); + } +} + +export class NotFoundError extends AppError { + constructor(message: string = 'Ressource non trouvée', requestId?: string) { + super(ErrorCode.NOT_FOUND, message, 404, true, undefined, requestId); + } +} + +export class RateLimitError extends AppError { + constructor(message: string, requestId?: string) { + super(ErrorCode.RATE_LIMITED, message, 429, true, undefined, requestId); + } +} + +export class ExternalServiceError extends AppError { + constructor(service: string, message: string, requestId?: string) { + const serviceCode = `${service.toUpperCase()}_SERVICE_ERROR` as ErrorCode; + super(serviceCode, `Erreur service externe ${service}: ${message}`, 502, true, undefined, requestId); + } +} + +export class BusinessRuleError extends AppError { + constructor(message: string, details?: ErrorDetails[], requestId?: string) { + super(ErrorCode.BUSINESS_RULE_VIOLATION, message, 400, true, details, requestId); + } +} diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..17695a0 --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,13 @@ +import { Session } from '../types'; + +declare global { + namespace Express { + interface Request { + session?: Session; + idNotUser?: { + idNot: string; + authToken: string; + }; + } + } +} diff --git a/src/utils/auth-tokens.ts b/src/utils/auth-tokens.ts new file mode 100644 index 0000000..d7bcd95 --- /dev/null +++ b/src/utils/auth-tokens.ts @@ -0,0 +1,4 @@ +import { AuthToken } from '../types'; + +// Auth tokens storage +export const authTokens: AuthToken[] = []; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..d5dfb2e --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,81 @@ +interface LogContext { + requestId?: string; + userId?: string; + error?: any; + request?: any; + response?: any; + duration?: number; + [key: string]: any; +} + +export class Logger { + private static formatMessage(level: string, message: string, context?: LogContext): string { + const timestamp = new Date().toISOString(); + const baseLog = { + timestamp, + level, + message, + ...context + }; + + return JSON.stringify(baseLog, null, process.env.NODE_ENV === 'development' ? 2 : 0); + } + + static info(message: string, context?: LogContext): void { + console.log(this.formatMessage('INFO', message, context)); + } + + static warn(message: string, context?: LogContext): void { + console.warn(this.formatMessage('WARN', message, context)); + } + + static error(message: string, context?: LogContext): void { + console.error(this.formatMessage('ERROR', message, context)); + } + + static debug(message: string, context?: LogContext): void { + if (process.env.NODE_ENV === 'development') { + console.debug(this.formatMessage('DEBUG', message, context)); + } + } + + // Helper for HTTP request logging + static logRequest(req: any, res: any, duration: number): void { + const context = { + requestId: req.headers['x-request-id'], + request: { + method: req.method, + url: req.url, + userAgent: req.get('User-Agent'), + ip: req.ip + }, + response: { + statusCode: res.statusCode + }, + duration + }; + + if (res.statusCode >= 400) { + this.error(`HTTP ${req.method} ${req.url} - ${res.statusCode}`, context); + } else { + this.info(`HTTP ${req.method} ${req.url} - ${res.statusCode}`, context); + } + } + + // Helper for service operation logging + static logServiceOperation(service: string, operation: string, success: boolean, context?: LogContext): void { + const message = `${service}.${operation} ${success ? 'succeeded' : 'failed'}`; + const logContext = { + service, + operation, + success, + ...context + }; + + if (success) { + this.info(message, logContext); + } else { + this.error(message, logContext); + } + } +} diff --git a/src/utils/result.ts b/src/utils/result.ts new file mode 100644 index 0000000..6693fa5 --- /dev/null +++ b/src/utils/result.ts @@ -0,0 +1,50 @@ +// Result pattern for better error handling in services +export interface ServiceResult { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: any; + }; +} + +export class Result { + static success(data: T): ServiceResult { + return { + success: true, + data + }; + } + + static failure(code: string, message: string, details?: any): ServiceResult { + return { + success: false, + error: { + code, + message, + details + } + }; + } + + static fromError(error: Error, code: string = 'UNKNOWN_ERROR'): ServiceResult { + return { + success: false, + error: { + code, + message: error.message, + details: error.stack + } + }; + } +} + +// Type guard to check if result is successful +export function isSuccess(result: ServiceResult): result is ServiceResult & { data: T } { + return result.success && result.data !== undefined; +} + +export function isFailure(result: ServiceResult): result is ServiceResult & { error: NonNullable['error']> } { + return !result.success && result.error !== undefined; +} diff --git a/src/utils/session-manager.ts b/src/utils/session-manager.ts new file mode 100644 index 0000000..94ef75c --- /dev/null +++ b/src/utils/session-manager.ts @@ -0,0 +1,50 @@ +import { v4 as uuidv4 } from 'uuid'; +import { Session } from '../types'; + +// Session storage for verified users +export const verifiedSessions = new Map(); + +export class SessionManager { + static generateSessionId(): string { + return uuidv4(); + } + + static createSession(phoneNumber: string, userData: any = {}): string { + const sessionId = this.generateSessionId(); + const session: Session = { + id: sessionId, + phoneNumber, + userData, + createdAt: Date.now(), + expiresAt: Date.now() + (1 * 60 * 1000) // 1 minute + }; + + verifiedSessions.set(sessionId, session); + return sessionId; + } + + static getSession(sessionId: string): Session | null { + const session = verifiedSessions.get(sessionId); + if (!session) return null; + + if (Date.now() > session.expiresAt) { + verifiedSessions.delete(sessionId); + return null; + } + + return session; + } + + static deleteSession(sessionId: string): void { + verifiedSessions.delete(sessionId); + } + + static cleanupExpiredSessions(): void { + const now = Date.now(); + for (const [sessionId, session] of verifiedSessions) { + if (now > session.expiresAt) { + verifiedSessions.delete(sessionId); + } + } + } +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..593cb53 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,196 @@ +import { ValidationError, ErrorDetails } from '../types/errors'; + +export interface ValidationRule { + field: string; + required?: boolean; + type?: 'string' | 'number' | 'email' | 'phone' | 'boolean' | 'array' | 'object'; + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + pattern?: RegExp; + custom?: (value: any) => string | null; // Returns error message or null if valid +} + +export class Validator { + static validate(data: any, rules: ValidationRule[], requestId?: string): void { + const errors: ErrorDetails[] = []; + + for (const rule of rules) { + const value = data[rule.field]; + const fieldErrors: string[] = []; + + // Check required fields + if (rule.required && (value === undefined || value === null || value === '')) { + fieldErrors.push(`${rule.field} est requis`); + continue; // Skip other validations if field is missing + } + + // Skip validation if field is optional and empty + if (!rule.required && (value === undefined || value === null || value === '')) { + continue; + } + + // Type validation + if (rule.type) { + switch (rule.type) { + case 'email': + if (!this.isValidEmail(value)) { + fieldErrors.push('Format d\'email invalide'); + } + break; + case 'phone': + if (!this.isValidPhone(value)) { + fieldErrors.push('Format de téléphone invalide'); + } + break; + case 'string': + if (typeof value !== 'string') { + fieldErrors.push('Doit être une chaîne de caractères'); + } + break; + case 'number': + if (typeof value !== 'number' || isNaN(value)) { + fieldErrors.push('Doit être un nombre valide'); + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + fieldErrors.push('Doit être un booléen'); + } + break; + case 'array': + if (!Array.isArray(value)) { + fieldErrors.push('Doit être un tableau'); + } + break; + case 'object': + if (typeof value !== 'object' || Array.isArray(value)) { + fieldErrors.push('Doit être un objet'); + } + break; + } + } + + // Length validation for strings + if (typeof value === 'string') { + if (rule.minLength !== undefined && value.length < rule.minLength) { + fieldErrors.push(`Doit contenir au moins ${rule.minLength} caractères`); + } + if (rule.maxLength !== undefined && value.length > rule.maxLength) { + fieldErrors.push(`Doit contenir au maximum ${rule.maxLength} caractères`); + } + } + + // Numeric range validation + if (typeof value === 'number') { + if (rule.min !== undefined && value < rule.min) { + fieldErrors.push(`Doit être supérieur ou égal à ${rule.min}`); + } + if (rule.max !== undefined && value > rule.max) { + fieldErrors.push(`Doit être inférieur ou égal à ${rule.max}`); + } + } + + // Pattern validation + if (rule.pattern && typeof value === 'string') { + if (!rule.pattern.test(value)) { + fieldErrors.push('Format invalide'); + } + } + + // Custom validation + if (rule.custom) { + const customError = rule.custom(value); + if (customError) { + fieldErrors.push(customError); + } + } + + // Add errors for this field + if (fieldErrors.length > 0) { + errors.push({ + field: rule.field, + value: value, + constraints: fieldErrors + }); + } + } + + if (errors.length > 0) { + throw new ValidationError('Erreurs de validation', errors, requestId); + } + } + + private static isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + private static isValidPhone(phone: string): boolean { + const phoneRegex = /^(\+[1-9]\d{1,14}|0\d{9,14})$/; + return phoneRegex.test(phone); + } + + // Predefined validation rule sets + static phoneRules(field: string = 'phoneNumber'): ValidationRule[] { + return [ + { + field, + required: true, + type: 'phone' + } + ]; + } + + static emailRules(field: string = 'email'): ValidationRule[] { + return [ + { + field, + required: true, + type: 'email' + } + ]; + } + + static subscriptionRules(): ValidationRule[] { + return [ + { + field: 'type', + required: true, + type: 'string', + custom: (value) => { + if (!['STANDARD', 'UNLIMITED'].includes(value)) { + return 'Type d\'abonnement invalide'; + } + return null; + } + }, + { + field: 'frequency', + required: true, + type: 'string', + custom: (value) => { + if (!['monthly', 'yearly'].includes(value)) { + return 'Fréquence invalide'; + } + return null; + } + }, + { + field: 'seats', + required: false, + type: 'number', + min: 1, + custom: (value) => { + // Only validate seats if type is STANDARD + const type = value?.type; + if (type === 'STANDARD' && (!value || value < 1)) { + return 'Nombre de sièges requis pour l\'abonnement Standard'; + } + return null; + } + } + ]; + } +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000..5baada9 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,33 @@ +export class Validators { + static validatePhoneNumber(phoneNumber: string): boolean { + if (!phoneNumber) return false; + const phoneRegex = /^(\+[1-9]\d{1,14}|0\d{9,14})$/; + return phoneRegex.test(phoneNumber); + } + + static validateEmail(email: string): boolean { + if (!email) return false; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + static validateSubscription(type: string, seats?: number, frequency?: string): { valid: boolean; message?: string } { + if (!type || !['STANDARD', 'UNLIMITED'].includes(type)) { + return { valid: false, message: 'Type d\'abonnement invalide' }; + } + + if (type === 'STANDARD' && (!seats || seats < 1)) { + return { valid: false, message: 'Nombre de sièges invalide' }; + } + + if (!frequency || !['monthly', 'yearly'].includes(frequency)) { + return { valid: false, message: 'Fréquence invalide' }; + } + + return { valid: true }; + } + + static validateTableName(tableName: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName); + } +} diff --git a/src/utils/verification-codes.ts b/src/utils/verification-codes.ts new file mode 100644 index 0000000..0a180f6 --- /dev/null +++ b/src/utils/verification-codes.ts @@ -0,0 +1,4 @@ +import { VerificationCode } from '../types'; + +// Codes storage +export const verificationCodes = new Map(); diff --git a/test-signer-reconnection.js b/test-signer-reconnection.js new file mode 100755 index 0000000..5ddb648 --- /dev/null +++ b/test-signer-reconnection.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node + +/** + * Test script to verify signer reconnection behavior + * + * Usage: + * node test-signer-reconnection.js + * + * This script will: + * 1. Connect to your API + * 2. Monitor signer health + * 3. Simulate operations during connection issues + * 4. Verify reconnection behavior + */ + +const fetch = require('node-fetch'); + +const API_BASE = process.env.API_BASE || 'http://localhost:8080'; +const TEST_DURATION = 60000; // 1 minute test +const CHECK_INTERVAL = 2000; // Check every 2 seconds + +async function checkSignerHealth() { + try { + const response = await fetch(`${API_BASE}/api/v1/process/health/signer`, { + timeout: 5000 + }); + + if (!response.ok) { + return { error: `HTTP ${response.status}` }; + } + + const data = await response.json(); + return data.data?.signer || { error: 'Invalid response format' }; + } catch (error) { + return { error: error.message }; + } +} + +async function forceReconnect() { + try { + const response = await fetch(`${API_BASE}/api/v1/process/admin/signer/reconnect`, { + method: 'POST', + timeout: 5000 + }); + + const data = await response.json(); + return data; + } catch (error) { + return { error: error.message }; + } +} + +function formatTimestamp() { + return new Date().toISOString().substr(11, 8); // HH:MM:SS +} + +function formatDuration(ms) { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms/1000).toFixed(1)}s`; + return `${(ms/60000).toFixed(1)}m`; +} + +async function runTest() { + console.log(`🧪 Starting Signer Reconnection Test`); + console.log(`📍 API Base: ${API_BASE}`); + console.log(`⏱️ Test Duration: ${formatDuration(TEST_DURATION)}`); + console.log(`🔄 Check Interval: ${formatDuration(CHECK_INTERVAL)}`); + console.log(`\n${'='.repeat(80)}\n`); + + const startTime = Date.now(); + let lastState = null; + let stateChanges = []; + let totalChecks = 0; + let healthyChecks = 0; + let errors = []; + + console.log(`Time | State | Attempts | Last Connected | Details`); + console.log(`${'-'.repeat(80)}`); + + const testInterval = setInterval(async () => { + const checkTime = Date.now(); + const health = await checkSignerHealth(); + totalChecks++; + + const timestamp = formatTimestamp(); + + if (health.error) { + errors.push({ time: checkTime, error: health.error }); + console.log(`${timestamp} | ❌ ERROR | - | - | ${health.error}`); + } else { + healthyChecks++; + + const state = health.state || 'unknown'; + const attempts = health.reconnectAttempts || 0; + const lastConnected = health.lastConnected ? + formatDuration(checkTime - health.lastConnected) + ' ago' : + 'never'; + const lastError = health.lastError ? ` (${health.lastError})` : ''; + + // Track state changes + if (lastState !== state) { + stateChanges.push({ + time: checkTime, + from: lastState, + to: state, + duration: lastState ? checkTime - stateChanges[stateChanges.length - 1]?.time : 0 + }); + lastState = state; + } + + const stateIcon = { + 'connected': '✅', + 'connecting': '🔄', + 'reconnecting': '🔄', + 'disconnected': '🔌', + 'failed': '❌' + }[state] || '❓'; + + console.log(`${timestamp} | ${stateIcon} ${state.padEnd(10)} | ${attempts.toString().padStart(8)} | ${lastConnected.padEnd(14)} | ${lastError}`); + } + + // Check if test duration is over + if (Date.now() - startTime >= TEST_DURATION) { + clearInterval(testInterval); + await printTestSummary(startTime, totalChecks, healthyChecks, errors, stateChanges); + } + }, CHECK_INTERVAL); + + // Handle Ctrl+C gracefully + process.on('SIGINT', async () => { + console.log('\n\n⏹️ Test interrupted by user'); + clearInterval(testInterval); + await printTestSummary(startTime, totalChecks, healthyChecks, errors, stateChanges); + process.exit(0); + }); + + // Optional: Test force reconnection after 30 seconds + setTimeout(async () => { + console.log(`\n${formatTimestamp()} | 🔧 TESTING | - | - | Forcing reconnection...`); + const result = await forceReconnect(); + if (result.error) { + console.log(`${formatTimestamp()} | ❌ ERROR | - | - | Force reconnect failed: ${result.error}`); + } else { + console.log(`${formatTimestamp()} | 🔧 TESTING | - | - | Force reconnection initiated`); + } + }, 30000); +} + +async function printTestSummary(startTime, totalChecks, healthyChecks, errors, stateChanges) { + const duration = Date.now() - startTime; + const successRate = totalChecks > 0 ? (healthyChecks / totalChecks * 100).toFixed(1) : 0; + + console.log(`\n${'='.repeat(80)}`); + console.log(`📊 Test Summary`); + console.log(`${'='.repeat(80)}`); + console.log(`⏱️ Total Duration: ${formatDuration(duration)}`); + console.log(`📈 Health Checks: ${healthyChecks}/${totalChecks} (${successRate}% success)`); + console.log(`🔄 State Changes: ${stateChanges.length}`); + console.log(`❌ Errors: ${errors.length}`); + + if (stateChanges.length > 0) { + console.log(`\n📋 State Change Timeline:`); + stateChanges.forEach((change, i) => { + const time = formatTimestamp(new Date(change.time)); + const duration = change.duration ? ` (${formatDuration(change.duration)})` : ''; + console.log(` ${time} | ${change.from || 'initial'} → ${change.to}${duration}`); + }); + } + + if (errors.length > 0) { + console.log(`\n❌ Error Summary:`); + const errorCounts = {}; + errors.forEach(error => { + errorCounts[error.error] = (errorCounts[error.error] || 0) + 1; + }); + Object.entries(errorCounts).forEach(([error, count]) => { + console.log(` ${count}x: ${error}`); + }); + } + + // Recommendations + console.log(`\n💡 Recommendations:`); + if (successRate < 95) { + console.log(` ⚠️ Low success rate (${successRate}%) - check signer service health`); + } + if (stateChanges.filter(c => c.to === 'connected').length === 0) { + console.log(` ⚠️ Never achieved connected state - check configuration`); + } + if (errors.length > totalChecks * 0.1) { + console.log(` ⚠️ High error rate - check network connectivity`); + } + if (stateChanges.length > 10) { + console.log(` ⚠️ Frequent state changes - possible connection instability`); + } + + if (successRate >= 95 && stateChanges.some(c => c.to === 'connected')) { + console.log(` ✅ Connection resilience looks good!`); + } + + console.log(`\n🔗 Manual Commands:`); + console.log(` Health Check: curl ${API_BASE}/api/v1/process/health/signer`); + console.log(` Force Reconnect: curl -X POST ${API_BASE}/api/v1/process/admin/signer/reconnect`); +} + +function formatTimestamp(date = new Date()) { + return date.toISOString().substr(11, 8); +} + +// Run the test +runTest().catch(error => { + console.error('❌ Test failed:', error.message); + process.exit(1); +});