Heavy refactoring
This commit is contained in:
parent
8462b99586
commit
06a6b5c7aa
8
src/config/email.ts
Normal file
8
src/config/email.ts
Normal 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
13
src/config/idnot.ts
Normal 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
16
src/config/index.ts
Normal 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
9
src/config/signer.ts
Normal 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
12
src/config/sms.ts
Normal 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
5
src/config/stripe.ts
Normal 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',
|
||||
};
|
105
src/controllers/email.controller.ts
Normal file
105
src/controllers/email.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
7
src/controllers/health.controller.ts
Normal file
7
src/controllers/health.controller.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export class HealthController {
|
||||
static getHealth(req: Request, res: Response) {
|
||||
res.json({ message: 'OK' });
|
||||
}
|
||||
}
|
199
src/controllers/idnot.controller.ts
Normal file
199
src/controllers/idnot.controller.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
449
src/controllers/process-improved.controller.ts
Normal file
449
src/controllers/process-improved.controller.ts
Normal 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()
|
||||
});
|
||||
});
|
||||
}
|
326
src/controllers/process.controller.ts
Normal file
326
src/controllers/process.controller.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
182
src/controllers/sms-improved.controller.ts
Normal file
182
src/controllers/sms-improved.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
126
src/controllers/sms.controller.ts
Normal file
126
src/controllers/sms.controller.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
113
src/controllers/stripe.controller.ts
Normal file
113
src/controllers/stripe.controller.ts
Normal 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
46
src/middleware/auth.ts
Normal 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();
|
||||
};
|
147
src/middleware/error-handler.ts
Normal file
147
src/middleware/error-handler.ts
Normal 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
25
src/middleware/session.ts
Normal 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();
|
||||
};
|
60
src/middleware/validation.ts
Normal file
60
src/middleware/validation.ts
Normal 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();
|
||||
};
|
11
src/routes/email.routes.ts
Normal file
11
src/routes/email.routes.ts
Normal 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 };
|
8
src/routes/health.routes.ts
Normal file
8
src/routes/health.routes.ts
Normal 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 };
|
17
src/routes/idnot.routes.ts
Normal file
17
src/routes/idnot.routes.ts
Normal 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
19
src/routes/index.ts
Normal 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 };
|
20
src/routes/process-improved.routes.ts
Normal file
20
src/routes/process-improved.routes.ts
Normal 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 };
|
16
src/routes/process.routes.ts
Normal file
16
src/routes/process.routes.ts
Normal 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
10
src/routes/sms.routes.ts
Normal 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 };
|
18
src/routes/stripe.routes.ts
Normal file
18
src/routes/stripe.routes.ts
Normal 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 };
|
1681
src/server.ts
1681
src/server.ts
File diff suppressed because it is too large
Load Diff
107
src/services/email/index.ts
Normal file
107
src/services/email/index.ts
Normal 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
159
src/services/idnot/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
482
src/services/signer-improved/index.ts
Normal file
482
src/services/signer-improved/index.ts
Normal 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;
|
||||
}
|
||||
}
|
18
src/services/signer/index.ts
Normal file
18
src/services/signer/index.ts
Normal 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();
|
||||
}
|
||||
}
|
170
src/services/sms-improved/index.ts
Normal file
170
src/services/sms-improved/index.ts
Normal 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
71
src/services/sms/index.ts
Normal 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);
|
||||
}
|
||||
}
|
108
src/services/stripe/index.ts
Normal file
108
src/services/stripe/index.ts
Normal 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
128
src/types/errors.ts
Normal 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
13
src/types/express.d.ts
vendored
Normal 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
4
src/utils/auth-tokens.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { AuthToken } from '../types';
|
||||
|
||||
// Auth tokens storage
|
||||
export const authTokens: AuthToken[] = [];
|
81
src/utils/logger.ts
Normal file
81
src/utils/logger.ts
Normal 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
50
src/utils/result.ts
Normal 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;
|
||||
}
|
50
src/utils/session-manager.ts
Normal file
50
src/utils/session-manager.ts
Normal 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
196
src/utils/validation.ts
Normal 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
33
src/utils/validators.ts
Normal 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);
|
||||
}
|
||||
}
|
4
src/utils/verification-codes.ts
Normal file
4
src/utils/verification-codes.ts
Normal 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
213
test-signer-reconnection.js
Executable 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);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user