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 };
|
1707
src/server.ts
1707
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