Rollback to 9d67724e
This commit is contained in:
165
server/_core/emailSender.ts
Normal file
165
server/_core/emailSender.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Service d'envoi d'emails réels via Resend API
|
||||
*
|
||||
* Configuration requise:
|
||||
* - RESEND_API_KEY: Clé API Resend (obtenir sur https://resend.com)
|
||||
* - RESEND_FROM_EMAIL: Adresse email d'envoi (doit être vérifiée dans Resend)
|
||||
*
|
||||
* Si ces variables ne sont pas configurées, les emails seront simulés
|
||||
* et envoyés comme notifications au propriétaire du projet.
|
||||
*/
|
||||
|
||||
import { ENV } from "./env";
|
||||
import { notifyOwner } from "./notification";
|
||||
import { getActiveEmailConfig } from "../db";
|
||||
|
||||
interface EmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResendEmailRequest {
|
||||
from: string;
|
||||
to: string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email via Resend API
|
||||
*/
|
||||
async function sendViaResend(params: EmailParams, config?: { apiKey: string; fromEmail: string; fromName: string }): Promise<boolean> {
|
||||
// Utiliser la config fournie ou les variables d'environnement
|
||||
const resendApiKey = config?.apiKey || process.env.RESEND_API_KEY;
|
||||
const fromEmail = config?.fromEmail || process.env.RESEND_FROM_EMAIL || 'noreply@manusvm.computer';
|
||||
const fromName = config?.fromName || 'Formation Manager Itinova';
|
||||
|
||||
if (!resendApiKey) {
|
||||
console.warn('[Email] RESEND_API_KEY non configurée, email simulé');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: ResendEmailRequest = {
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: [params.to],
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
};
|
||||
|
||||
// Ajouter les pièces jointes si présentes
|
||||
if (params.attachments && params.attachments.length > 0) {
|
||||
payload.attachments = params.attachments.map(att => ({
|
||||
filename: att.filename,
|
||||
content: att.content, // Resend accepte base64 ou string
|
||||
}));
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('[Email] Erreur Resend:', response.status, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[Email] Email envoyé via Resend:', result.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Email] Exception lors de l\'envoi via Resend:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simule l'envoi d'un email en créant une notification pour le propriétaire
|
||||
*/
|
||||
async function simulateEmail(params: EmailParams): Promise<boolean> {
|
||||
console.log('=== EMAIL SIMULÉ ===');
|
||||
console.log('To:', params.to);
|
||||
console.log('Subject:', params.subject);
|
||||
|
||||
const emailContent = `
|
||||
Destinataire: ${params.to}
|
||||
Sujet: ${params.subject}
|
||||
|
||||
${params.html.replace(/<[^>]*>/g, '').substring(0, 500)}...
|
||||
|
||||
${params.attachments ? `Pièces jointes: ${params.attachments.map(a => a.filename).join(', ')}` : ''}
|
||||
|
||||
⚠️ Cet email est simulé. Pour envoyer de vrais emails:
|
||||
1. Créez un compte sur https://resend.com
|
||||
2. Ajoutez RESEND_API_KEY dans les secrets du projet
|
||||
3. Ajoutez RESEND_FROM_EMAIL (ex: noreply@votredomaine.com)`;
|
||||
|
||||
try {
|
||||
await notifyOwner({
|
||||
title: `📧 Email simulé: ${params.subject}`,
|
||||
content: emailContent,
|
||||
});
|
||||
console.log('✓ Email simulé (notification envoyée au propriétaire)');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la simulation d\'email:', error);
|
||||
}
|
||||
|
||||
console.log('===================');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email (réel ou simulé selon la configuration)
|
||||
*/
|
||||
export async function sendEmail(params: EmailParams): Promise<boolean> {
|
||||
// Récupérer la configuration depuis la base de données
|
||||
const config = await getActiveEmailConfig();
|
||||
|
||||
// Si mode simulation ou pas de config, simuler
|
||||
if (!config || config.mode === 'simulation') {
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
// Si mode production, tenter d'envoyer via Resend
|
||||
if (config.mode === 'production' && config.apiKey) {
|
||||
const sent = await sendViaResend(params, {
|
||||
apiKey: config.apiKey,
|
||||
fromEmail: config.fromEmail,
|
||||
fromName: config.fromName,
|
||||
});
|
||||
|
||||
// Si l'envoi échoue, simuler en fallback
|
||||
if (!sent) {
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: simuler
|
||||
return await simulateEmail(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si le service d'envoi d'emails réel est configuré
|
||||
*/
|
||||
export function isEmailServiceConfigured(): boolean {
|
||||
return !!process.env.RESEND_API_KEY;
|
||||
}
|
||||
60
server/dateUtils.ts
Normal file
60
server/dateUtils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utilitaires pour la gestion des dates
|
||||
* Gère correctement les conversions entre heure locale et UTC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une chaîne datetime-local (format: "2025-01-15T14:30") en Date
|
||||
* en construisant la date avec une chaîne ISO qui force l'heure locale
|
||||
* Préserve l'heure locale sans conversion UTC
|
||||
*/
|
||||
export function parseLocalDateTime(dateTimeString: string): Date {
|
||||
if (!dateTimeString) {
|
||||
throw new Error('Date string is required');
|
||||
}
|
||||
|
||||
// Le format datetime-local est "YYYY-MM-DDTHH:mm"
|
||||
const match = dateTimeString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid date format: ${dateTimeString}. Expected YYYY-MM-DDTHH:mm`);
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
|
||||
// Créer un objet Date en utilisant le constructeur avec composants
|
||||
// MySQL stocke en UTC, donc on doit compenser le décalage horaire
|
||||
const date = new Date(
|
||||
parseInt(year),
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hours),
|
||||
parseInt(minutes),
|
||||
0
|
||||
);
|
||||
|
||||
// Compenser le décalage UTC en ajoutant le décalage du fuseau horaire
|
||||
// Cela garantit que l'heure stockée en UTC correspond à l'heure locale saisie
|
||||
const offset = date.getTimezoneOffset(); // en minutes (négatif pour Europe/Paris)
|
||||
date.setMinutes(date.getMinutes() - offset);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid date string: ${dateTimeString}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une Date en chaîne datetime-local pour les inputs HTML
|
||||
* Format: "YYYY-MM-DDTHH:mm"
|
||||
*/
|
||||
export function formatToLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
572
server/db.ts
572
server/db.ts
@@ -1,6 +1,30 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import { InsertUser, users } from "../drizzle/schema";
|
||||
import {
|
||||
InsertUser,
|
||||
users,
|
||||
formations,
|
||||
apprenants,
|
||||
sequences,
|
||||
datesFormation,
|
||||
inscriptions,
|
||||
passwordResetTokens,
|
||||
InsertPasswordResetToken,
|
||||
emailTemplates,
|
||||
emailConfig,
|
||||
InsertEmailConfig,
|
||||
InsertSequence,
|
||||
InsertDateFormation,
|
||||
InsertInscription,
|
||||
InsertFormation,
|
||||
InsertApprenant,
|
||||
Sequence,
|
||||
DateFormation,
|
||||
Apprenant,
|
||||
Formation,
|
||||
EmailTemplate,
|
||||
InsertEmailTemplate
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
@@ -89,4 +113,546 @@ export async function getUserByOpenId(openId: string) {
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
// TODO: add feature queries here as your schema grows.
|
||||
// ==================== FORMATIONS ====================
|
||||
|
||||
export async function createFormation(data: InsertFormation) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(formations).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getFormations() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(formations);
|
||||
}
|
||||
|
||||
export async function getFormationById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(formations).where(eq(formations.id, id)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function getFormationByLien(lien: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(formations).where(eq(formations.lienUnique, lien)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function updateFormation(id: number, data: Partial<InsertFormation>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(formations).set(data).where(eq(formations.id, id));
|
||||
}
|
||||
|
||||
export async function deleteFormation(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(formations).where(eq(formations.id, id));
|
||||
}
|
||||
|
||||
// ==================== SÉQUENCES ====================
|
||||
|
||||
export async function createSequence(data: InsertSequence) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(sequences).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getSequences() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(sequences);
|
||||
}
|
||||
|
||||
export async function getSequenceById(id: number): Promise<Sequence | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(sequences).where(eq(sequences.id, id)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function getSequencesByFormation(formationId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(sequences).where(eq(sequences.formationId, formationId));
|
||||
}
|
||||
|
||||
export async function updateSequence(id: number, data: Partial<InsertSequence>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(sequences).set(data).where(eq(sequences.id, id));
|
||||
}
|
||||
|
||||
export async function deleteSequence(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(sequences).where(eq(sequences.id, id));
|
||||
}
|
||||
|
||||
// ==================== DATES DE FORMATION ====================
|
||||
|
||||
export async function createDateFormation(data: InsertDateFormation) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(datesFormation).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getDatesBySequence(sequenceId: number): Promise<DateFormation[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, sequenceId))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function deleteDatesBySequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(datesFormation).where(eq(datesFormation.sequenceId, sequenceId));
|
||||
}
|
||||
|
||||
// ==================== APPRENANTS ====================
|
||||
|
||||
export async function createApprenant(data: InsertApprenant) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(apprenants).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getApprenants() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(apprenants);
|
||||
}
|
||||
|
||||
export async function getApprenantById(id: number): Promise<Apprenant | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(apprenants).where(eq(apprenants.id, id)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function getApprenantByEmail(email: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(apprenants).where(eq(apprenants.email, email)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function updateApprenant(id: number, data: Partial<InsertApprenant>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(apprenants).set(data).where(eq(apprenants.id, id));
|
||||
}
|
||||
|
||||
export async function deleteApprenant(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(apprenants).where(eq(apprenants.id, id));
|
||||
}
|
||||
|
||||
// ==================== INSCRIPTIONS ====================
|
||||
|
||||
export async function createInscription(data: InsertInscription) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(inscriptions).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAllInscriptions() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getInscriptionsBySequence(sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db.select({
|
||||
inscription: inscriptions,
|
||||
apprenant: apprenants,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
|
||||
.where(eq(inscriptions.sequenceId, sequenceId));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getInscriptionsByApprenant(apprenantId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const results = await db.select({
|
||||
inscription: inscriptions,
|
||||
sequence: sequences,
|
||||
})
|
||||
.from(inscriptions)
|
||||
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
|
||||
.where(eq(inscriptions.apprenantId, apprenantId));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function checkExistingInscription(apprenantId: number, sequenceId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select()
|
||||
.from(inscriptions)
|
||||
.where(and(
|
||||
eq(inscriptions.apprenantId, apprenantId),
|
||||
eq(inscriptions.sequenceId, sequenceId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function countInscriptionsBySequence(sequenceId: number, statut?: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
|
||||
const conditions = [eq(inscriptions.sequenceId, sequenceId)];
|
||||
if (statut) {
|
||||
conditions.push(eq(inscriptions.statut, statut as any));
|
||||
}
|
||||
|
||||
const result = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(inscriptions)
|
||||
.where(and(...conditions));
|
||||
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
export async function updateInscription(id: number, data: Partial<InsertInscription>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(inscriptions).set(data).where(eq(inscriptions.id, id));
|
||||
}
|
||||
|
||||
export async function deleteInscription(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(inscriptions).where(eq(inscriptions.id, id));
|
||||
}
|
||||
|
||||
// ==================== GESTION DES UTILISATEURS ====================
|
||||
|
||||
export async function createUser(data: InsertUser) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(users).values(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getAllUsers() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(users);
|
||||
}
|
||||
|
||||
export async function getUserById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, data: Partial<InsertUser>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(users).set(data).where(eq(users.id, id));
|
||||
}
|
||||
|
||||
export async function toggleUserStatus(id: number, isActive: boolean) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(users).set({ isActive }).where(eq(users.id, id));
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
}
|
||||
|
||||
|
||||
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
|
||||
|
||||
export async function createPasswordResetToken(userId: number, token: string, expiresAt: Date) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const result = await db.insert(passwordResetTokens).values({
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
used: false,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getPasswordResetToken(token: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db.select()
|
||||
.from(passwordResetTokens)
|
||||
.where(eq(passwordResetTokens.token, token))
|
||||
.limit(1);
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function markTokenAsUsed(tokenId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.update(passwordResetTokens)
|
||||
.set({ used: true })
|
||||
.where(eq(passwordResetTokens.id, tokenId));
|
||||
}
|
||||
|
||||
export async function deleteExpiredTokens() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const now = new Date();
|
||||
await db.delete(passwordResetTokens)
|
||||
.where(sql`${passwordResetTokens.expiresAt} < ${now}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Email Templates Management
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Récupère tous les templates d'emails
|
||||
*/
|
||||
export async function getAllEmailTemplates() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
return await db.select().from(emailTemplates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un template par son type
|
||||
*/
|
||||
export async function getEmailTemplateByType(type: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
|
||||
return results.length > 0 ? results[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou met à jour un template d'email
|
||||
*/
|
||||
export async function upsertEmailTemplate(template: InsertEmailTemplate) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const existing = await getEmailTemplateByType(template.type);
|
||||
|
||||
if (existing) {
|
||||
// Mise à jour
|
||||
await db.update(emailTemplates)
|
||||
.set({
|
||||
name: template.name,
|
||||
logoUrl: template.logoUrl,
|
||||
primaryColor: template.primaryColor,
|
||||
headerBgColor: template.headerBgColor,
|
||||
headerTextColor: template.headerTextColor,
|
||||
headerTitle: template.headerTitle,
|
||||
footerText: template.footerText,
|
||||
active: template.active,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emailTemplates.type, template.type));
|
||||
|
||||
return await getEmailTemplateByType(template.type);
|
||||
} else {
|
||||
// Création
|
||||
await db.insert(emailTemplates).values(template);
|
||||
return await getEmailTemplateByType(template.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un template d'email
|
||||
*/
|
||||
export async function deleteEmailTemplate(type: string) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise les templates par défaut si la table est vide
|
||||
*/
|
||||
export async function initializeDefaultEmailTemplates() {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
const existing = await getAllEmailTemplates();
|
||||
if (existing.length > 0) return; // Déjà initialisé
|
||||
|
||||
const defaultTemplates: InsertEmailTemplate[] = [
|
||||
{
|
||||
type: "inscription",
|
||||
name: "Confirmation d'inscription",
|
||||
logoUrl: null,
|
||||
primaryColor: "#2563eb",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "Formation Manager Itinova",
|
||||
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
type: "teaser",
|
||||
name: "Email teaser",
|
||||
logoUrl: null,
|
||||
primaryColor: "#2563eb",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "Formation Manager Itinova",
|
||||
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
type: "rappel",
|
||||
name: "Rappel J-7",
|
||||
logoUrl: null,
|
||||
primaryColor: "#2563eb",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "Formation Manager Itinova",
|
||||
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
type: "reset_password",
|
||||
name: "Réinitialisation de mot de passe",
|
||||
logoUrl: null,
|
||||
primaryColor: "#2563eb",
|
||||
headerBgColor: "#2563eb",
|
||||
headerTextColor: "#ffffff",
|
||||
headerTitle: "Formation Manager Itinova",
|
||||
footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const template of defaultTemplates) {
|
||||
await db.insert(emailTemplates).values(template);
|
||||
}
|
||||
|
||||
console.log("[DB] Templates d'emails par défaut initialisés");
|
||||
}
|
||||
|
||||
|
||||
// ==================== Email Config ====================
|
||||
|
||||
/**
|
||||
* Récupère la configuration email active
|
||||
*/
|
||||
export async function getActiveEmailConfig() {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(emailConfig)
|
||||
.where(eq(emailConfig.active, true))
|
||||
.limit(1);
|
||||
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou met à jour la configuration email
|
||||
*/
|
||||
export async function upsertEmailConfig(data: InsertEmailConfig) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
// Désactiver toutes les configurations existantes
|
||||
await db.update(emailConfig).set({ active: false });
|
||||
|
||||
// Créer la nouvelle configuration
|
||||
await db.insert(emailConfig).values({
|
||||
...data,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration email
|
||||
*/
|
||||
export async function updateEmailConfig(id: number, data: Partial<InsertEmailConfig>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
await db
|
||||
.update(emailConfig)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(emailConfig.id, id));
|
||||
}
|
||||
|
||||
331
server/emailService.ts
Normal file
331
server/emailService.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Service d'envoi d'emails pour les formations
|
||||
* Utilise le système de notification Manus pour envoyer des emails
|
||||
*/
|
||||
|
||||
import { generateFormationICS } from "./icsGenerator";
|
||||
import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender";
|
||||
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
|
||||
|
||||
interface EmailParams {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email via le service d'envoi configuré
|
||||
* Utilise Resend si RESEND_API_KEY est configuré, sinon simule l'envoi
|
||||
*/
|
||||
async function sendEmail(params: EmailParams): Promise<boolean> {
|
||||
return await sendEmailViaService(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template HTML de base pour les emails (avec template personnalisé)
|
||||
*/
|
||||
async function getEmailTemplate(content: string, templateType: string = 'inscription'): Promise<string> {
|
||||
return await generateEmailFromTemplate(templateType, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email de confirmation d'inscription avec invitations Outlook pour toutes les dates
|
||||
*/
|
||||
export async function sendInscriptionConfirmation(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantFonction: string;
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
statut: 'confirmee' | 'liste_attente';
|
||||
}): Promise<boolean> {
|
||||
const isConfirmed = params.statut === 'confirmee';
|
||||
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
|
||||
params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre';
|
||||
|
||||
// Générer la liste des dates
|
||||
const datesHTML = params.dates.map(date => `
|
||||
<div class="date-item">
|
||||
<strong>Date ${date.ordre} :</strong><br/>
|
||||
Du ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}<br/>
|
||||
au ${date.dateFin.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const content = `
|
||||
<h2>Confirmation d'inscription</h2>
|
||||
<p>Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},</p>
|
||||
|
||||
${isConfirmed
|
||||
? '<p><strong>Votre inscription a été confirmée !</strong></p>'
|
||||
: '<p><strong>Vous avez été ajouté à la liste d\'attente.</strong></p>'
|
||||
}
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Détails de la formation</h3>
|
||||
<p><strong>Formation :</strong> ${params.formationNom}</p>
|
||||
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
|
||||
<p><strong>Lieu :</strong> ${params.lieu}</p>
|
||||
<h4>Dates de formation (${params.dates.length} séance${params.dates.length > 1 ? 's' : ''}) :</h4>
|
||||
${datesHTML}
|
||||
</div>
|
||||
|
||||
${isConfirmed
|
||||
? `<p>Des invitations Outlook sont jointes à cet email pour chaque date de formation. Merci de les ajouter à votre calendrier pour bloquer votre agenda.</p>
|
||||
<p><strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la séquence. Après cette date, aucune modification ne sera possible.</p>`
|
||||
: '<p>Nous vous contacterons dès qu\'une place se libère.</p>'
|
||||
}
|
||||
|
||||
<p>À bientôt pour cette formation !</p>
|
||||
`;
|
||||
|
||||
// Générer une invitation ICS pour chaque date
|
||||
const attachments = isConfirmed ? params.dates.map((date, index) => ({
|
||||
filename: `invitation_date_${date.ordre}.ics`,
|
||||
content: generateFormationICS({
|
||||
formationNom: params.formationNom,
|
||||
sessionNom: `${params.sequenceNom} - Date ${date.ordre}`,
|
||||
dateDebut: date.dateDebut,
|
||||
dateFin: date.dateFin,
|
||||
lieu: params.lieu,
|
||||
apprenantNom: params.apprenantNom,
|
||||
apprenantPrenom: params.apprenantPrenom,
|
||||
apprenantEmail: params.apprenantEmail,
|
||||
}),
|
||||
contentType: 'text/calendar',
|
||||
})) : undefined;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: isConfirmed
|
||||
? `Confirmation d'inscription - ${params.formationNom}`
|
||||
: `Liste d'attente - ${params.formationNom}`,
|
||||
html: await getEmailTemplate(content, 'inscription'),
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email teaser pour une séquence
|
||||
*/
|
||||
export async function sendTeaserEmail(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantNom: string;
|
||||
apprenantFonction: string;
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
}): Promise<boolean> {
|
||||
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
|
||||
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
|
||||
const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom;
|
||||
|
||||
const datesHTML = params.dates.map(date => `
|
||||
<div class="date-item">
|
||||
<strong>Date ${date.ordre} :</strong> ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const content = `
|
||||
<h2>Votre formation approche !</h2>
|
||||
<p>Bonjour ${salutation},</p>
|
||||
|
||||
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${params.formationNom}</strong>.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
|
||||
<h4>Dates :</h4>
|
||||
${datesHTML}
|
||||
</div>
|
||||
|
||||
<p>Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: `Votre formation ${params.formationNom} approche !`,
|
||||
html: await getEmailTemplate(content, 'teaser'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email de rappel J-7
|
||||
*/
|
||||
export async function sendRappelJ7Email(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantNom: string;
|
||||
apprenantFonction: string;
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
}): Promise<boolean> {
|
||||
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
|
||||
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
|
||||
const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom;
|
||||
|
||||
const datesHTML = params.dates.map(date => `
|
||||
<div class="date-item">
|
||||
<strong>Date ${date.ordre} :</strong><br/>
|
||||
${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const content = `
|
||||
<h2>Rappel : Votre formation dans 7 jours</h2>
|
||||
<p>Bonjour ${salutation},</p>
|
||||
|
||||
<p>Nous vous rappelons que votre formation <strong>${params.formationNom}</strong> commence dans 7 jours.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Informations pratiques</h3>
|
||||
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
|
||||
<p><strong>Lieu :</strong> ${params.lieu}</p>
|
||||
<h4>Dates :</h4>
|
||||
${datesHTML}
|
||||
</div>
|
||||
|
||||
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
|
||||
|
||||
<p>Si vous avez des questions, n'hésitez pas à contacter le service RH.</p>
|
||||
|
||||
<p>À très bientôt !</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: `Rappel J-7 : ${params.formationNom}`,
|
||||
html: await getEmailTemplate(content, 'rappel'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie des emails groupés à tous les inscrits d'une séquence
|
||||
*/
|
||||
export async function sendGroupEmail(params: {
|
||||
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
type: 'teaser' | 'rappel';
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu?: string;
|
||||
}): Promise<{ sent: number; failed: number }> {
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const recipient of params.recipients) {
|
||||
try {
|
||||
if (params.type === 'teaser') {
|
||||
await sendTeaserEmail({
|
||||
apprenantEmail: recipient.email,
|
||||
apprenantPrenom: recipient.prenom,
|
||||
apprenantNom: recipient.nom,
|
||||
apprenantFonction: recipient.fonction,
|
||||
formationNom: params.formationNom,
|
||||
sequenceNom: params.sequenceNom,
|
||||
dates: params.dates,
|
||||
});
|
||||
} else {
|
||||
await sendRappelJ7Email({
|
||||
apprenantEmail: recipient.email,
|
||||
apprenantPrenom: recipient.prenom,
|
||||
apprenantNom: recipient.nom,
|
||||
apprenantFonction: recipient.fonction,
|
||||
formationNom: params.formationNom,
|
||||
sequenceNom: params.sequenceNom,
|
||||
dates: params.dates,
|
||||
lieu: params.lieu || '',
|
||||
});
|
||||
}
|
||||
sent++;
|
||||
} catch (error) {
|
||||
console.error(`Erreur envoi email à ${recipient.email}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email de réinitialisation de mot de passe
|
||||
*/
|
||||
export async function sendPasswordResetEmail(params: {
|
||||
apprenantEmail: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
resetLink: string;
|
||||
}): Promise<boolean> {
|
||||
const content = `
|
||||
<h2>Réinitialisation de mot de passe</h2>
|
||||
<p>Bonjour ${params.apprenantPrenom} ${params.apprenantNom},</p>
|
||||
|
||||
<p>Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.</p>
|
||||
|
||||
<p>Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${params.resetLink}" class="button">Réinitialiser mon mot de passe</a>
|
||||
</div>
|
||||
|
||||
<p>Ou copiez ce lien dans votre navigateur :</p>
|
||||
<div style="background-color: white; padding: 15px; border-radius: 4px; word-break: break-all; font-family: monospace; font-size: 12px; border: 1px solid #e5e7eb;">
|
||||
${params.resetLink}
|
||||
</div>
|
||||
|
||||
<div class="info-box" style="background-color: #fef3c7; border-left-color: #f59e0b;">
|
||||
<p><strong>⚠️ Important :</strong></p>
|
||||
<ul>
|
||||
<li>Ce lien est valide pendant <strong>24 heures</strong> seulement</li>
|
||||
<li>Il ne peut être utilisé qu'<strong>une seule fois</strong></li>
|
||||
<li>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Pour toute question, contactez le service RH.</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: params.apprenantEmail,
|
||||
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
|
||||
html: await getEmailTemplate(content, 'reset_password'),
|
||||
});
|
||||
}
|
||||
161
server/emailTemplateGenerator.ts
Normal file
161
server/emailTemplateGenerator.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Générateur de templates d'emails personnalisés
|
||||
* Utilise les templates stockés en base de données
|
||||
*/
|
||||
|
||||
import * as db from "./db";
|
||||
import { EmailTemplate } from "../drizzle/schema";
|
||||
|
||||
/**
|
||||
* Génère le HTML d'un email en utilisant le template personnalisé
|
||||
*/
|
||||
export async function generateEmailFromTemplate(
|
||||
templateType: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
// Récupérer le template depuis la base de données
|
||||
const template = await db.getEmailTemplateByType(templateType);
|
||||
|
||||
// Si pas de template trouvé, utiliser le template par défaut
|
||||
if (!template) {
|
||||
return getDefaultEmailTemplate(content);
|
||||
}
|
||||
|
||||
return buildEmailHTML(template, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le HTML de l'email avec le template
|
||||
*/
|
||||
function buildEmailHTML(template: EmailTemplate, content: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
}
|
||||
.header {
|
||||
background-color: ${template.headerBgColor};
|
||||
color: ${template.headerTextColor};
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
${template.logoUrl ? `
|
||||
.header img {
|
||||
max-width: 150px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}` : ''}
|
||||
.content {
|
||||
background-color: #f9fafb;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.content h2 {
|
||||
color: ${template.primaryColor};
|
||||
margin-top: 0;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f3f4f6;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${template.primaryColor};
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #dbeafe;
|
||||
border-left: 4px solid ${template.primaryColor};
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.date-item {
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${template.logoUrl ? `<img src="${template.logoUrl}" alt="Logo" />` : ''}
|
||||
<h1>${template.headerTitle}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
${content}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>${template.footerText || 'Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template par défaut si aucun template personnalisé n'est trouvé
|
||||
*/
|
||||
function getDefaultEmailTemplate(content: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: white; }
|
||||
.header { background-color: #2563eb; color: white; padding: 30px 20px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { background-color: #f9fafb; padding: 30px 20px; }
|
||||
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
|
||||
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Formation Manager Itinova</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
${content}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Cet email a été envoyé automatiquement par le système de gestion des formations Itinova.</p>
|
||||
<p>Pour toute question, veuillez contacter le service RH.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
234
server/exportService.ts
Normal file
234
server/exportService.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Service d'export des données en Excel et PDF
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface InscriptionExport {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
statut: string;
|
||||
dateInscription: Date;
|
||||
}
|
||||
|
||||
interface SequenceInfo {
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
publicCible: string;
|
||||
inscriptions: InscriptionExport[];
|
||||
}
|
||||
|
||||
interface ApprenantPresence {
|
||||
nom: string;
|
||||
prenom: string;
|
||||
codeEtablissement: string;
|
||||
fonction: string;
|
||||
}
|
||||
|
||||
interface FeuillePresenceInfo {
|
||||
formationNom: string;
|
||||
sequenceNom: string;
|
||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||
lieu: string;
|
||||
publicCible: string;
|
||||
apprenants: ApprenantPresence[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier Excel avec la liste des inscrits
|
||||
*/
|
||||
export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
// Préparer les données
|
||||
const data = sequenceInfo.inscriptions.map(i => ({
|
||||
'Nom': i.nom,
|
||||
'Prénom': i.prenom,
|
||||
'Email': i.email,
|
||||
'Code établissement': i.codeEtablissement,
|
||||
'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'),
|
||||
}));
|
||||
|
||||
// Créer le workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Créer la feuille avec les informations de séquence
|
||||
const infoData = [
|
||||
['Formation', sequenceInfo.formationNom],
|
||||
['Séquence', sequenceInfo.sequenceNom],
|
||||
['Lieu', sequenceInfo.lieu],
|
||||
['Public cible', sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'],
|
||||
['Nombre de dates', sequenceInfo.dates.length.toString()],
|
||||
[],
|
||||
['DATES DE FORMATION'],
|
||||
];
|
||||
|
||||
// Ajouter les dates
|
||||
sequenceInfo.dates.forEach(date => {
|
||||
infoData.push([
|
||||
`Date ${date.ordre}`,
|
||||
`Du ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} au ${date.dateFin.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`
|
||||
]);
|
||||
});
|
||||
|
||||
infoData.push([]);
|
||||
infoData.push(['Nombre d\'inscrits', sequenceInfo.inscriptions.length.toString()]);
|
||||
infoData.push([]);
|
||||
|
||||
// Créer la feuille principale
|
||||
const ws = XLSX.utils.aoa_to_sheet(infoData);
|
||||
|
||||
// Ajouter les données des inscrits
|
||||
XLSX.utils.sheet_add_json(ws, data, { origin: -1 });
|
||||
|
||||
// Ajouter la feuille au workbook
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
|
||||
|
||||
// Générer le buffer
|
||||
const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
return excelBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier PDF avec la liste des inscrits
|
||||
*/
|
||||
export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Liste des inscrits', 14, 20);
|
||||
|
||||
// Informations de séquence
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Nombre de dates : ${sequenceInfo.dates.length}`, 14, yPos);
|
||||
yPos += 8;
|
||||
|
||||
// Dates de formation
|
||||
doc.setFontSize(9);
|
||||
sequenceInfo.dates.forEach(date => {
|
||||
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
yPos += 5;
|
||||
});
|
||||
|
||||
yPos += 3;
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Nombre d'inscrits : ${sequenceInfo.inscriptions.length}`, 14, yPos);
|
||||
|
||||
// Tableau des inscrits
|
||||
const tableData = sequenceInfo.inscriptions.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.email,
|
||||
i.codeEtablissement,
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée',
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Fonction', 'Statut']],
|
||||
body: tableData,
|
||||
styles: { fontSize: 9 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une feuille de présence PDF
|
||||
*/
|
||||
export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Titre
|
||||
doc.setFontSize(18);
|
||||
doc.text('Feuille de présence', 14, 20);
|
||||
|
||||
// Informations de séquence
|
||||
doc.setFontSize(10);
|
||||
let yPos = 35;
|
||||
doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos);
|
||||
yPos += 6;
|
||||
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : 'Autre'}`, 14, yPos);
|
||||
yPos += 8;
|
||||
|
||||
// Dates de formation
|
||||
doc.setFontSize(9);
|
||||
feuilleInfo.dates.forEach(date => {
|
||||
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`, 14, yPos);
|
||||
yPos += 5;
|
||||
});
|
||||
|
||||
// Tableau de présence
|
||||
const tableData = feuilleInfo.apprenants.map(i => [
|
||||
i.nom,
|
||||
i.prenom,
|
||||
i.codeEtablissement,
|
||||
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
|
||||
'', // Colonne signature
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPos + 10,
|
||||
head: [['Nom', 'Prénom', 'Code établissement', 'Fonction', 'Signature']],
|
||||
body: tableData,
|
||||
styles: { fontSize: 10, cellPadding: 5 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
columnStyles: {
|
||||
4: { cellWidth: 40 }, // Largeur pour la colonne signature
|
||||
},
|
||||
});
|
||||
|
||||
return Buffer.from(doc.output('arraybuffer'));
|
||||
}
|
||||
109
server/icsGenerator.ts
Normal file
109
server/icsGenerator.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Module de génération de fichiers ICS (iCalendar) pour les invitations Outlook
|
||||
*/
|
||||
|
||||
interface ICSEvent {
|
||||
summary: string;
|
||||
description?: string;
|
||||
location: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
attendeeEmail: string;
|
||||
attendeeName: string;
|
||||
organizerEmail?: string;
|
||||
organizerName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date au format iCalendar (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UID unique pour l'événement
|
||||
*/
|
||||
function generateUID(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@itinova.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux pour le format ICS
|
||||
*/
|
||||
function escapeICS(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une invitation Outlook
|
||||
*/
|
||||
export function generateICS(event: ICSEvent): string {
|
||||
const now = new Date();
|
||||
const uid = generateUID();
|
||||
|
||||
const icsContent = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Itinova//Formation Manager//FR',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${formatICSDate(now)}`,
|
||||
`DTSTART:${formatICSDate(event.startDate)}`,
|
||||
`DTEND:${formatICSDate(event.endDate)}`,
|
||||
`SUMMARY:${escapeICS(event.summary)}`,
|
||||
event.description ? `DESCRIPTION:${escapeICS(event.description)}` : '',
|
||||
`LOCATION:${escapeICS(event.location)}`,
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:OPAQUE', // Marque comme "occupé" dans le calendrier
|
||||
'SEQUENCE:0',
|
||||
`ORGANIZER;CN=${escapeICS(event.organizerName || 'Formation Itinova')}:mailto:${event.organizerEmail || 'formation@itinova.com'}`,
|
||||
`ATTENDEE;CN=${escapeICS(event.attendeeName)};RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:${event.attendeeEmail}`,
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-P1D', // Rappel 1 jour avant
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:Rappel: ${escapeICS(event.summary)}`,
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].filter(line => line !== '').join('\r\n');
|
||||
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une session de formation
|
||||
*/
|
||||
export function generateFormationICS(params: {
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantEmail: string;
|
||||
}): string {
|
||||
return generateICS({
|
||||
summary: `Formation: ${params.formationNom} - ${params.sessionNom}`,
|
||||
description: `Vous êtes inscrit à la formation "${params.formationNom}".\n\nSession: ${params.sessionNom}\n\nMerci de vous présenter à l'heure indiquée.`,
|
||||
location: params.lieu,
|
||||
startDate: params.dateDebut,
|
||||
endDate: params.dateFin,
|
||||
attendeeEmail: params.apprenantEmail,
|
||||
attendeeName: `${params.apprenantPrenom} ${params.apprenantNom}`,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, router } from "./_core/trpc";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { parseLocalDateTime } from "./dateUtils";
|
||||
import { z } from "zod";
|
||||
import * as db from "./db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService";
|
||||
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
|
||||
|
||||
// Procédure admin uniquement
|
||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
if (ctx.user.role !== 'admin') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
|
||||
}
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
|
||||
system: systemRouter,
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
@@ -17,12 +30,687 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// TODO: add feature routers here, e.g.
|
||||
// todo: router({
|
||||
// list: protectedProcedure.query(({ ctx }) =>
|
||||
// db.getUserTodos(ctx.user.id)
|
||||
// ),
|
||||
// }),
|
||||
// ===== FORMATIONS =====
|
||||
formations: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getFormations();
|
||||
}),
|
||||
|
||||
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getFormationById(input.id);
|
||||
}),
|
||||
|
||||
getByLien: publicProcedure.input(z.object({ lien: z.string() })).query(async ({ input }) => {
|
||||
return db.getFormationByLien(input.lien);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
lienUnique: z.string().min(1),
|
||||
actif: z.boolean().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createFormation(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
lienUnique: z.string().min(1).optional(),
|
||||
actif: z.boolean().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateFormation(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteFormation(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== APPRENANTS =====
|
||||
apprenants: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getApprenants();
|
||||
}),
|
||||
|
||||
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getApprenantById(input.id);
|
||||
}),
|
||||
|
||||
getByEmail: publicProcedure.input(z.object({ email: z.string() })).query(async ({ input }) => {
|
||||
return db.getApprenantByEmail(input.email);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
prenom: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
codeEtablissement: z.string().min(1),
|
||||
fonction: z.enum(["directeur", "chef_service", "autre"]),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createApprenant(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().min(1).optional(),
|
||||
prenom: z.string().min(1).optional(),
|
||||
email: z.string().email().optional(),
|
||||
codeEtablissement: z.string().min(1).optional(),
|
||||
fonction: z.enum(["directeur", "chef_service", "autre"]).optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateApprenant(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteApprenant(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== SÉQUENCES =====
|
||||
sequences: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
const seqs = await db.getSequences();
|
||||
// Récupérer les dates pour chaque séquence
|
||||
const sequencesAvecDates = await Promise.all(
|
||||
seqs.map(async (seq) => {
|
||||
const dates = await db.getDatesBySequence(seq.id);
|
||||
return { ...seq, dates };
|
||||
})
|
||||
);
|
||||
return sequencesAvecDates;
|
||||
}),
|
||||
|
||||
listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
|
||||
const seqs = await db.getSequencesByFormation(input.formationId);
|
||||
// Récupérer les dates et le nombre d'inscrits pour chaque séquence
|
||||
const sequencesAvecDates = await Promise.all(
|
||||
seqs.map(async (seq) => {
|
||||
const dates = await db.getDatesBySequence(seq.id);
|
||||
const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee');
|
||||
return { ...seq, dates, nbInscrits };
|
||||
})
|
||||
);
|
||||
return sequencesAvecDates;
|
||||
}),
|
||||
|
||||
getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
const seq = await db.getSequenceById(input.id);
|
||||
if (!seq) return null;
|
||||
const dates = await db.getDatesBySequence(input.id);
|
||||
return { ...seq, dates };
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
formationId: z.number(),
|
||||
nom: z.string().min(1),
|
||||
lieu: z.string().min(1),
|
||||
publicCible: z.enum(["directeur", "chef_service", "autre"]),
|
||||
capaciteMax: z.number().default(12),
|
||||
dateBlocage: z.string(),
|
||||
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
|
||||
dates: z.array(z.object({
|
||||
dateDebut: z.string(),
|
||||
dateFin: z.string(),
|
||||
ordre: z.number(),
|
||||
})).min(1).max(4),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { dates, ...sequenceData } = input;
|
||||
|
||||
// Créer la séquence
|
||||
const result = await db.createSequence({
|
||||
...sequenceData,
|
||||
dateBlocage: parseLocalDateTime(sequenceData.dateBlocage),
|
||||
});
|
||||
|
||||
// Récupérer l'ID de la séquence créée
|
||||
const sequenceId = Number(result[0].insertId);
|
||||
|
||||
// Créer les dates de formation
|
||||
for (const date of dates) {
|
||||
await db.createDateFormation({
|
||||
sequenceId,
|
||||
dateDebut: parseLocalDateTime(date.dateDebut),
|
||||
dateFin: parseLocalDateTime(date.dateFin),
|
||||
ordre: date.ordre,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, sequenceId };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
formationId: z.number(),
|
||||
nom: z.string().min(1),
|
||||
lieu: z.string().min(1),
|
||||
publicCible: z.enum(["directeur", "chef_service", "autre"]),
|
||||
capaciteMax: z.number(),
|
||||
dateBlocage: z.string(),
|
||||
statut: z.enum(["ouverte", "bloquee", "terminee"]),
|
||||
dates: z.array(z.object({
|
||||
dateDebut: z.string(),
|
||||
dateFin: z.string(),
|
||||
ordre: z.number(),
|
||||
})).min(1).max(4),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, dates, ...sequenceData } = input;
|
||||
|
||||
console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2));
|
||||
|
||||
// Mettre à jour la séquence
|
||||
const updateData: any = {
|
||||
...sequenceData,
|
||||
dateBlocage: parseLocalDateTime(sequenceData.dateBlocage),
|
||||
};
|
||||
|
||||
console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2));
|
||||
await db.updateSequence(id, updateData);
|
||||
console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès');
|
||||
|
||||
// Supprimer les anciennes dates
|
||||
await db.deleteDatesBySequence(id);
|
||||
console.log('[UPDATE SEQUENCE] Anciennes dates supprimées');
|
||||
|
||||
// Créer les nouvelles dates
|
||||
for (const date of dates) {
|
||||
console.log('[UPDATE SEQUENCE] Création date:', date);
|
||||
await db.createDateFormation({
|
||||
sequenceId: id,
|
||||
dateDebut: parseLocalDateTime(date.dateDebut),
|
||||
dateFin: parseLocalDateTime(date.dateFin),
|
||||
ordre: date.ordre,
|
||||
});
|
||||
}
|
||||
console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées');
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
// Supprimer d'abord les dates associées
|
||||
await db.deleteDatesBySequence(input.id);
|
||||
// Puis supprimer la séquence
|
||||
await db.deleteSequence(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== INSCRIPTIONS =====
|
||||
inscriptions: router({
|
||||
listAll: adminProcedure.query(async () => {
|
||||
return db.getAllInscriptions();
|
||||
}),
|
||||
|
||||
listBySequence: adminProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => {
|
||||
return db.getInscriptionsBySequence(input.sequenceId);
|
||||
}),
|
||||
|
||||
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
|
||||
return db.getInscriptionsByApprenant(input.apprenantId);
|
||||
}),
|
||||
|
||||
checkExisting: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sequenceId: z.number(),
|
||||
})).query(async ({ input }) => {
|
||||
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
||||
}),
|
||||
|
||||
inscrire: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sequenceId: z.number(),
|
||||
})).mutation(async ({ input }) => {
|
||||
// Vérifier si la séquence existe
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
}
|
||||
|
||||
// Vérifier si la séquence est bloquée
|
||||
const now = new Date();
|
||||
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier si l'apprenant a déjà une inscription
|
||||
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Vous êtes déjà inscrit à cette séquence'
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier la capacité
|
||||
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
||||
const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee';
|
||||
|
||||
await db.createInscription({
|
||||
apprenantId: input.apprenantId,
|
||||
sequenceId: input.sequenceId,
|
||||
statut,
|
||||
});
|
||||
|
||||
// Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates
|
||||
const inscriptionSequence = await db.getSequenceById(input.sequenceId);
|
||||
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
|
||||
const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null;
|
||||
const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : [];
|
||||
|
||||
if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) {
|
||||
// Utiliser la première date pour l'email principal
|
||||
const premiereDate = dates[0];
|
||||
|
||||
await sendInscriptionConfirmation({
|
||||
apprenantEmail: inscriptionApprenant.email,
|
||||
apprenantNom: inscriptionApprenant.nom,
|
||||
apprenantPrenom: inscriptionApprenant.prenom,
|
||||
apprenantFonction: inscriptionApprenant.fonction,
|
||||
formationNom: inscriptionFormation.nom,
|
||||
sequenceNom: inscriptionSequence.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: new Date(d.dateDebut),
|
||||
dateFin: new Date(d.dateFin),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
lieu: inscriptionSequence.lieu,
|
||||
statut,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, statut };
|
||||
}),
|
||||
|
||||
desinscrire: publicProcedure.input(z.object({
|
||||
apprenantId: z.number(),
|
||||
sequenceId: z.number(),
|
||||
})).mutation(async ({ input }) => {
|
||||
// Vérifier si la séquence existe
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
}
|
||||
|
||||
// Vérifier si la séquence est bloquée
|
||||
const now = new Date();
|
||||
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
||||
});
|
||||
}
|
||||
|
||||
// Trouver l'inscription
|
||||
const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
||||
if (!inscription) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
|
||||
}
|
||||
|
||||
await db.updateInscription(inscription.id, { statut: 'annulee' });
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
updateStatut: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.updateInscription(input.id, { statut: input.statut });
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
sendGroupEmail: adminProcedure.input(z.object({
|
||||
sequenceId: z.number(),
|
||||
type: z.enum(['teaser', 'rappel']),
|
||||
})).mutation(async ({ input }) => {
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
}
|
||||
|
||||
const formation = await db.getFormationById(sequence.formationId);
|
||||
if (!formation) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
}
|
||||
|
||||
const dates = await db.getDatesBySequence(sequence.id);
|
||||
if (dates.length === 0) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
|
||||
}
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
||||
const recipients = inscriptions
|
||||
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
||||
.map(i => ({
|
||||
email: i.apprenant!.email,
|
||||
prenom: i.apprenant!.prenom,
|
||||
nom: i.apprenant!.nom,
|
||||
fonction: i.apprenant!.fonction,
|
||||
}));
|
||||
|
||||
const result = await sendGroupEmail({
|
||||
recipients,
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
type: input.type,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: new Date(d.dateDebut),
|
||||
dateFin: new Date(d.dateFin),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
lieu: sequence.lieu,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(sequence.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
||||
const dates = await db.getDatesBySequence(input.sequenceId);
|
||||
|
||||
const data = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
email: i.apprenant!.email,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
fonction: i.apprenant!.fonction,
|
||||
statut: i.inscription.statut,
|
||||
dateInscription: i.inscription.dateInscription,
|
||||
}));
|
||||
|
||||
const buffer = await generateExcelExport({
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: new Date(d.dateDebut),
|
||||
dateFin: new Date(d.dateFin),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
lieu: sequence.lieu,
|
||||
publicCible: sequence.publicCible,
|
||||
inscriptions: data,
|
||||
});
|
||||
|
||||
return {
|
||||
buffer: buffer.toString('base64'),
|
||||
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
|
||||
};
|
||||
}),
|
||||
|
||||
exportPDF: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(sequence.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
||||
const dates = await db.getDatesBySequence(input.sequenceId);
|
||||
|
||||
const data = inscriptions
|
||||
.filter(i => i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
email: i.apprenant!.email,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
fonction: i.apprenant!.fonction,
|
||||
statut: i.inscription.statut,
|
||||
dateInscription: i.inscription.dateInscription,
|
||||
}));
|
||||
|
||||
const buffer = await generatePDFExport({
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: new Date(d.dateDebut),
|
||||
dateFin: new Date(d.dateFin),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
lieu: sequence.lieu,
|
||||
publicCible: sequence.publicCible,
|
||||
inscriptions: data,
|
||||
});
|
||||
|
||||
return {
|
||||
buffer: buffer.toString('base64'),
|
||||
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
||||
};
|
||||
}),
|
||||
|
||||
exportFeuillePresence: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
||||
const sequence = await db.getSequenceById(input.sequenceId);
|
||||
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
||||
|
||||
const formation = await db.getFormationById(sequence.formationId);
|
||||
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
||||
|
||||
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
||||
const dates = await db.getDatesBySequence(input.sequenceId);
|
||||
|
||||
const data = inscriptions
|
||||
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
||||
.map(i => ({
|
||||
nom: i.apprenant!.nom,
|
||||
prenom: i.apprenant!.prenom,
|
||||
codeEtablissement: i.apprenant!.codeEtablissement,
|
||||
fonction: i.apprenant!.fonction,
|
||||
}));
|
||||
|
||||
const buffer = await generateFeuillePresence({
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: new Date(d.dateDebut),
|
||||
dateFin: new Date(d.dateFin),
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
lieu: sequence.lieu,
|
||||
publicCible: sequence.publicCible,
|
||||
apprenants: data,
|
||||
});
|
||||
|
||||
return {
|
||||
buffer: buffer.toString('base64'),
|
||||
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== GESTION DES UTILISATEURS =====
|
||||
users: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getAllUsers();
|
||||
}),
|
||||
|
||||
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||
return db.getUserById(input.id);
|
||||
}),
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
openId: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(["user", "admin"]).default("user"),
|
||||
isActive: z.boolean().default(true),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createUser(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
role: z.enum(["user", "admin"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateUser(id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
toggleStatus: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
isActive: z.boolean(),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.toggleUserStatus(input.id, input.isActive);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
await db.deleteUser(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
||||
const user = await db.getUserById(input.id);
|
||||
if (!user) {
|
||||
throw new Error("Utilisateur introuvable");
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
throw new Error("Cet utilisateur n'a pas d'adresse email");
|
||||
}
|
||||
|
||||
// Générer un token unique
|
||||
const crypto = await import('crypto');
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Définir l'expiration à 24h
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
// Sauvegarder le token en base
|
||||
await db.createPasswordResetToken(user.id, token, expiresAt);
|
||||
|
||||
// Générer le lien de réinitialisation
|
||||
// TODO: Remplacer par l'URL réelle de votre application en production
|
||||
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
|
||||
|
||||
// Envoyer l'email
|
||||
const emailService = await import('./emailService');
|
||||
const emailSent = await emailService.sendPasswordResetEmail({
|
||||
apprenantEmail: user.email,
|
||||
apprenantNom: user.name || 'Utilisateur',
|
||||
apprenantPrenom: '',
|
||||
resetLink,
|
||||
});
|
||||
|
||||
if (!emailSent) {
|
||||
throw new Error("Échec de l'envoi de l'email");
|
||||
}
|
||||
|
||||
return { success: true, message: "Email de réinitialisation envoyé" };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ===== GESTION DES TEMPLATES D'EMAILS =====
|
||||
emailTemplates: router({
|
||||
list: adminProcedure.query(async () => {
|
||||
return db.getAllEmailTemplates();
|
||||
}),
|
||||
|
||||
getByType: adminProcedure
|
||||
.input(z.object({ type: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.getEmailTemplateByType(input.type);
|
||||
}),
|
||||
|
||||
upsert: adminProcedure
|
||||
.input(z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
logoUrl: z.string().nullable(),
|
||||
primaryColor: z.string(),
|
||||
headerBgColor: z.string(),
|
||||
headerTextColor: z.string(),
|
||||
headerTitle: z.string(),
|
||||
footerText: z.string().nullable(),
|
||||
active: z.boolean(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
return db.upsertEmailTemplate(input);
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ type: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.deleteEmailTemplate(input.type);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
initializeDefaults: adminProcedure.mutation(async () => {
|
||||
await db.initializeDefaultEmailTemplates();
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
emailConfig: router({
|
||||
get: adminProcedure.query(async () => {
|
||||
return db.getActiveEmailConfig();
|
||||
}),
|
||||
|
||||
upsert: adminProcedure
|
||||
.input(z.object({
|
||||
provider: z.string(),
|
||||
apiKey: z.string().nullable(),
|
||||
fromEmail: z.string().email(),
|
||||
fromName: z.string(),
|
||||
mode: z.enum(["simulation", "production"]),
|
||||
domainVerified: z.boolean(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.upsertEmailConfig(input);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
testEmail: adminProcedure
|
||||
.input(z.object({
|
||||
toEmail: z.string().email(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
// Importer le service d'envoi
|
||||
const { sendEmail } = await import("./_core/emailSender");
|
||||
|
||||
try {
|
||||
const success = await sendEmail({
|
||||
to: input.toEmail,
|
||||
subject: "Test d'envoi d'email - Formation Manager Itinova",
|
||||
html: `
|
||||
<h1>Test réussi !</h1>
|
||||
<p>Cet email de test a été envoyé avec succès depuis votre configuration SMTP.</p>
|
||||
<p>Votre configuration d'envoi d'emails fonctionne correctement.</p>
|
||||
`,
|
||||
});
|
||||
|
||||
return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
Reference in New Issue
Block a user