Heavy refactoring

This commit is contained in:
Sosthene 2025-09-07 21:10:39 +02:00
parent 8462b99586
commit 06a6b5c7aa
44 changed files with 3943 additions and 1618 deletions

8
src/config/email.ts Normal file
View File

@ -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'
};

13
src/config/idnot.ts Normal file
View File

@ -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'
};

16
src/config/index.ts Normal file
View File

@ -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
}
};

9
src/config/signer.ts Normal file
View File

@ -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
};

12
src/config/sms.ts Normal file
View File

@ -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')
};

5
src/config/stripe.ts Normal file
View File

@ -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',
};

View File

@ -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;
}
}
}

View File

@ -0,0 +1,7 @@
import { Request, Response } from 'express';
export class HealthController {
static getHealth(req: Request, res: Response) {
res.json({ message: 'OK' });
}
}

View File

@ -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<any> {
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<any> {
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<any> {
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
}
});
}
}

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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()
});
});
}

View File

@ -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<any> {
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<any> {
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<any> {
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<any> {
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
});
}
}

View File

@ -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<void> => {
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<void> => {
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
);
}
}
});
}

View File

@ -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<any> {
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'
});
}
}
}
}

View File

@ -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<any> {
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'
});
}
}
}

46
src/middleware/auth.ts Normal file
View File

@ -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();
};

View File

@ -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();
};

25
src/middleware/session.ts Normal file
View File

@ -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();
};

View File

@ -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();
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

19
src/routes/index.ts Normal file
View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

10
src/routes/sms.routes.ts Normal file
View File

@ -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 };

View File

@ -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 };

File diff suppressed because it is too large Load Diff

107
src/services/email/index.ts Normal file
View File

@ -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<string, PendingEmail>();
export class EmailService {
static async sendTransactionalEmail(to: string, templateName: string, subject: string, templateVariables: Record<string, string>): 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<string, string>): 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<void> {
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();
}
}
}
}
}

159
src/services/idnot/index.ts Normal file
View File

@ -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;
}
}
}

View File

@ -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<ServiceResult<void>> {
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<ServiceResult<SDKSignerClient>> {
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<ServiceResult<void>> {
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<void> {
// 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<T>(
operation: (client: SDKSignerClient) => Promise<T>,
operationName: string,
maxRetries: number = 3
): Promise<ServiceResult<T>> {
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<ServiceResult<void>> {
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;
}
}

View File

@ -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<void> {
const client = this.getInstance();
await client.connect();
}
}

View File

@ -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<ServiceResult<SmsResult>> {
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<SmsResult>({
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<ServiceResult<SmsResult>> {
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<SmsResult>({
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<ServiceResult<SmsResult>> {
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<ServiceResult<{ ovh: boolean; smsfactor: boolean }>> {
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
});
}
}

71
src/services/sms/index.ts Normal file
View File

@ -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);
}
}

View File

@ -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<Stripe.Checkout.Session> {
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<Stripe.Subscription> {
return await this.client.subscriptions.retrieve(subscriptionId);
}
async createPortalSession(subscriptionId: string): Promise<Stripe.BillingPortal.Session> {
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;
}
}

128
src/types/errors.ts Normal file
View File

@ -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);
}
}

13
src/types/express.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import { Session } from '../types';
declare global {
namespace Express {
interface Request {
session?: Session;
idNotUser?: {
idNot: string;
authToken: string;
};
}
}
}

4
src/utils/auth-tokens.ts Normal file
View File

@ -0,0 +1,4 @@
import { AuthToken } from '../types';
// Auth tokens storage
export const authTokens: AuthToken[] = [];

81
src/utils/logger.ts Normal file
View File

@ -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);
}
}
}

50
src/utils/result.ts Normal file
View File

@ -0,0 +1,50 @@
// Result pattern for better error handling in services
export interface ServiceResult<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
};
}
export class Result {
static success<T>(data: T): ServiceResult<T> {
return {
success: true,
data
};
}
static failure<T>(code: string, message: string, details?: any): ServiceResult<T> {
return {
success: false,
error: {
code,
message,
details
}
};
}
static fromError<T>(error: Error, code: string = 'UNKNOWN_ERROR'): ServiceResult<T> {
return {
success: false,
error: {
code,
message: error.message,
details: error.stack
}
};
}
}
// Type guard to check if result is successful
export function isSuccess<T>(result: ServiceResult<T>): result is ServiceResult<T> & { data: T } {
return result.success && result.data !== undefined;
}
export function isFailure<T>(result: ServiceResult<T>): result is ServiceResult<T> & { error: NonNullable<ServiceResult<T>['error']> } {
return !result.success && result.error !== undefined;
}

View File

@ -0,0 +1,50 @@
import { v4 as uuidv4 } from 'uuid';
import { Session } from '../types';
// Session storage for verified users
export const verifiedSessions = new Map<string, Session>();
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);
}
}
}
}

196
src/utils/validation.ts Normal file
View File

@ -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;
}
}
];
}
}

33
src/utils/validators.ts Normal file
View File

@ -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);
}
}

View File

@ -0,0 +1,4 @@
import { VerificationCode } from '../types';
// Codes storage
export const verificationCodes = new Map<string, VerificationCode>();

213
test-signer-reconnection.js Executable file
View File

@ -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);
});