This commit is contained in:
Manus
2026-02-02 17:32:58 -05:00
parent 04001e7bcd
commit 70f9055073
94 changed files with 25859 additions and 5260 deletions

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeAll } from "vitest";
import { getDb } from "../db";
import { users } from "../../drizzle/schema";
import { eq } from "drizzle-orm";
describe("Local Authentication", () => {
beforeAll(async () => {
// Vérifier que la base de données est accessible
const db = await getDb();
expect(db).toBeDefined();
});
it("should have an admin user with username adminServFormation", async () => {
const db = await getDb();
if (!db) {
throw new Error("Database not available");
}
const result = await db
.select()
.from(users)
.where(eq(users.username, "adminServFormation"))
.limit(1);
expect(result.length).toBe(1);
expect(result[0].username).toBe("adminServFormation");
expect(result[0].role).toBe("admin");
expect(result[0].password).toBeDefined();
expect(result[0].password).not.toBeNull();
});
it("should authenticate with correct credentials", async () => {
const response = await fetch("http://localhost:3000/api/auth/local/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "adminServFormation",
password: "Itinova69!",
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.user).toBeDefined();
expect(data.user.username).toBe("adminServFormation");
expect(data.user.role).toBe("admin");
expect(data.user.password).toBeUndefined(); // Le mot de passe ne doit pas être retourné
});
it("should reject authentication with incorrect password", async () => {
const response = await fetch("http://localhost:3000/api/auth/local/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "adminServFormation",
password: "wrongpassword",
}),
});
expect(response.status).toBe(401);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("incorrect");
});
it("should reject authentication with non-existent username", async () => {
const response = await fetch("http://localhost:3000/api/auth/local/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "nonexistentuser",
password: "anypassword",
}),
});
expect(response.status).toBe(401);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("incorrect");
});
it("should reject authentication with missing credentials", async () => {
const response = await fetch("http://localhost:3000/api/auth/local/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: "adminServFormation",
// password manquant
}),
});
expect(response.status).toBe(400);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("requis");
});
});

View File

@@ -0,0 +1,32 @@
/**
* Récupère l'URL de base de l'application
* En production, utilise l'URL publique configurée
* En développement, utilise localhost
*/
export function getAppBaseUrl(): string {
// Si une URL publique est configurée, l'utiliser
if (process.env.APP_PUBLIC_URL) {
return process.env.APP_PUBLIC_URL;
}
// En production, essayer de détecter l'URL depuis les variables d'environnement Manus
if (process.env.NODE_ENV === "production") {
// Pour les applications Manus déployées, l'URL est généralement disponible
// via les variables d'environnement du système
const port = process.env.PORT || 3000;
// Si on est sur un serveur VPS avec un domaine configuré
if (process.env.DOMAIN_NAME) {
return `https://${process.env.DOMAIN_NAME}`;
}
// Sinon, utiliser l'URL du serveur si disponible
if (process.env.SERVER_URL) {
return process.env.SERVER_URL;
}
}
// Par défaut, utiliser localhost (développement)
const port = process.env.PORT || 3000;
return `http://localhost:${port}`;
}

View File

@@ -0,0 +1,28 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { User } from "../../drizzle/schema";
import { sdk } from "./sdk";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
res: CreateExpressContextOptions["res"];
user: User | null;
};
export async function createContext(
opts: CreateExpressContextOptions
): Promise<TrpcContext> {
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
} catch (error) {
// Authentication is optional for public procedures.
user = null;
}
return {
req: opts.req,
res: opts.res,
user,
};
}

View File

@@ -0,0 +1,48 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
const forwardedProto = req.headers["x-forwarded-proto"];
if (!forwardedProto) return false;
const protoList = Array.isArray(forwardedProto)
? forwardedProto
: forwardedProto.split(",");
return protoList.some(proto => proto.trim().toLowerCase() === "https");
}
export function getSessionCookieOptions(
req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname;
// const shouldSetDomain =
// hostname &&
// !LOCAL_HOSTS.has(hostname) &&
// !isIpAddress(hostname) &&
// hostname !== "127.0.0.1" &&
// hostname !== "::1";
// const domain =
// shouldSetDomain && !hostname.startsWith(".")
// ? `.${hostname}`
// : shouldSetDomain
// ? hostname
// : undefined;
return {
httpOnly: true,
path: "/",
sameSite: "lax",
secure: isSecureRequest(req),
};
}

View File

@@ -0,0 +1,64 @@
/**
* Quick example (matches curl usage):
* await callDataApi("Youtube/search", {
* query: { gl: "US", hl: "en", q: "manus" },
* })
*/
import { ENV } from "./env";
export type DataApiCallOptions = {
query?: Record<string, unknown>;
body?: Record<string, unknown>;
pathParams?: Record<string, unknown>;
formData?: Record<string, unknown>;
};
export async function callDataApi(
apiId: string,
options: DataApiCallOptions = {}
): Promise<unknown> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
apiId,
query: options.query,
body: options.body,
path_params: options.pathParams,
multipart_form_data: options.formData,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const payload = await response.json().catch(() => ({}));
if (payload && typeof payload === "object" && "jsonData" in payload) {
try {
return JSON.parse((payload as Record<string, string>).jsonData ?? "{}");
} catch {
return (payload as Record<string, unknown>).jsonData;
}
}
return payload;
}

View File

@@ -0,0 +1,206 @@
/**
* 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";
import { sendViaSMTP } from "./smtpSender";
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();
console.log('[Email] Configuration récupérée:', config ? {
provider: config.provider,
mode: config.mode,
fromEmail: config.fromEmail,
hasSmtpHost: !!config.smtpHost,
hasSmtpUser: !!config.smtpUser,
hasSmtpPassword: !!config.smtpPassword,
} : 'Aucune configuration');
// Si mode simulation ou pas de config, simuler
if (!config || config.mode === 'simulation') {
console.log('[Email] Mode simulation activé ou pas de config');
return await simulateEmail(params);
}
// Si mode production
if (config.mode === 'production') {
console.log('[Email] Mode production activé');
// Essayer SMTP en priorité si configuré
if (config.provider === 'smtp' && config.smtpHost && config.smtpPort && config.smtpUser && config.smtpPassword) {
console.log('[Email] Tentative d\'envoi via SMTP...');
const sent = await sendViaSMTP(params, {
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpSecure || 'tls',
user: config.smtpUser,
password: config.smtpPassword,
fromEmail: config.fromEmail,
fromName: config.fromName,
});
// Si l'envoi échoue, simuler en fallback
if (!sent) {
console.log('[Email] Échec de l\'envoi SMTP, basculement en simulation');
return await simulateEmail(params);
}
console.log('[Email] Email envoyé avec succès via SMTP');
return true;
}
// Sinon essayer Resend
if (config.provider === 'resend' && config.apiKey) {
console.log('[Email] Tentative d\'envoi via Resend...');
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;
}

View File

@@ -0,0 +1,11 @@
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
jwtSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
};

View File

@@ -0,0 +1,59 @@
import { Request, Response } from "express";
import multer from "multer";
import { storagePut } from "../storage";
import crypto from "crypto";
import path from "path";
// Configuration de multer pour l'upload en mémoire
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 Mo max
},
});
// Middleware pour l'upload d'un seul fichier
export const uploadSingle = upload.single("file");
// Handler pour l'upload de fichier vers S3
export async function handleFileUpload(req: Request, res: Response) {
try {
if (!req.file) {
return res.status(400).json({ error: "Aucun fichier fourni" });
}
const file = req.file;
// Générer un nom de fichier unique
const randomSuffix = crypto.randomBytes(8).toString("hex");
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
const fileName = `${safeBaseName}-${randomSuffix}${ext}`;
// Chemin S3 pour les pièces jointes de rappels
const s3Key = `rappels-attachments/${fileName}`;
// Upload vers S3
const { url, key } = await storagePut(
s3Key,
file.buffer,
file.mimetype
);
console.log(`[FileUpload] Fichier uploadé: ${fileName} (${file.size} octets)`);
return res.json({
url,
key,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
});
} catch (error) {
console.error("[FileUpload] Erreur:", error);
return res.status(500).json({
error: "Erreur lors de l'upload du fichier"
});
}
}

View File

@@ -0,0 +1,92 @@
/**
* Image generation helper using internal ImageService
*
* Example usage:
* const { url: imageUrl } = await generateImage({
* prompt: "A serene landscape with mountains"
* });
*
* For editing:
* const { url: imageUrl } = await generateImage({
* prompt: "Add a rainbow to this landscape",
* originalImages: [{
* url: "https://example.com/original.jpg",
* mimeType: "image/jpeg"
* }]
* });
*/
import { storagePut } from "server/storage";
import { ENV } from "./env";
export type GenerateImageOptions = {
prompt: string;
originalImages?: Array<{
url?: string;
b64Json?: string;
mimeType?: string;
}>;
};
export type GenerateImageResponse = {
url?: string;
};
export async function generateImage(
options: GenerateImageOptions
): Promise<GenerateImageResponse> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"images.v1.ImageService/GenerateImage",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
prompt: options.prompt,
original_images: options.originalImages || [],
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const result = (await response.json()) as {
image: {
b64Json: string;
mimeType: string;
};
};
const base64Data = result.image.b64Json;
const buffer = Buffer.from(base64Data, "base64");
// Save to S3
const { url } = await storagePut(
`generated/${Date.now()}.png`,
buffer,
result.image.mimeType
);
return {
url,
};
}

View File

@@ -0,0 +1,82 @@
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import localAuthRouter from "./localAuth";
import uploadImageRouter from "./uploadImage";
import uploadFileRouter from "./uploadFile";
import { serveStatic, setupVite } from "./vite";
import { initRappelScheduler } from "../rappelScheduler";
import { initRappelRetryScheduler } from "../rappelRetry";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
}
async function findAvailablePort(startPort: number = 3000): Promise<number> {
for (let port = startPort; port < startPort + 20; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found starting from ${startPort}`);
}
async function startServer() {
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// Local authentication under /api/auth/local
app.use("/api/auth/local", localAuthRouter);
// Image upload under /api/upload-image
app.use("/api/upload-image", uploadImageRouter);
// File upload under /api/upload-file
app.use("/api/upload-file", uploadFileRouter);
// tRPC API
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
})
);
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
}
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
// Initialiser le scheduler de rappels automatiques
initRappelScheduler();
// Initialiser le scheduler de réessai automatique
initRappelRetryScheduler();
});
}
startServer().catch(console.error);

View File

@@ -0,0 +1,332 @@
import { ENV } from "./env";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "low" | "high";
};
};
export type FileContent = {
type: "file_url";
file_url: {
url: string;
mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
};
};
export type MessageContent = string | TextContent | ImageContent | FileContent;
export type Message = {
role: Role;
content: MessageContent | MessageContent[];
name?: string;
tool_call_id?: string;
};
export type Tool = {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
};
export type ToolChoicePrimitive = "none" | "auto" | "required";
export type ToolChoiceByName = { name: string };
export type ToolChoiceExplicit = {
type: "function";
function: {
name: string;
};
};
export type ToolChoice =
| ToolChoicePrimitive
| ToolChoiceByName
| ToolChoiceExplicit;
export type InvokeParams = {
messages: Message[];
tools?: Tool[];
toolChoice?: ToolChoice;
tool_choice?: ToolChoice;
maxTokens?: number;
max_tokens?: number;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
};
export type ToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type InvokeResult = {
id: string;
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: Role;
content: string | Array<TextContent | ImageContent | FileContent>;
tool_calls?: ToolCall[];
};
finish_reason: string | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export type JsonSchema = {
name: string;
schema: Record<string, unknown>;
strict?: boolean;
};
export type OutputSchema = JsonSchema;
export type ResponseFormat =
| { type: "text" }
| { type: "json_object" }
| { type: "json_schema"; json_schema: JsonSchema };
const ensureArray = (
value: MessageContent | MessageContent[]
): MessageContent[] => (Array.isArray(value) ? value : [value]);
const normalizeContentPart = (
part: MessageContent
): TextContent | ImageContent | FileContent => {
if (typeof part === "string") {
return { type: "text", text: part };
}
if (part.type === "text") {
return part;
}
if (part.type === "image_url") {
return part;
}
if (part.type === "file_url") {
return part;
}
throw new Error("Unsupported message content part");
};
const normalizeMessage = (message: Message) => {
const { role, name, tool_call_id } = message;
if (role === "tool" || role === "function") {
const content = ensureArray(message.content)
.map(part => (typeof part === "string" ? part : JSON.stringify(part)))
.join("\n");
return {
role,
name,
tool_call_id,
content,
};
}
const contentParts = ensureArray(message.content).map(normalizeContentPart);
// If there's only text content, collapse to a single string for compatibility
if (contentParts.length === 1 && contentParts[0].type === "text") {
return {
role,
name,
content: contentParts[0].text,
};
}
return {
role,
name,
content: contentParts,
};
};
const normalizeToolChoice = (
toolChoice: ToolChoice | undefined,
tools: Tool[] | undefined
): "none" | "auto" | ToolChoiceExplicit | undefined => {
if (!toolChoice) return undefined;
if (toolChoice === "none" || toolChoice === "auto") {
return toolChoice;
}
if (toolChoice === "required") {
if (!tools || tools.length === 0) {
throw new Error(
"tool_choice 'required' was provided but no tools were configured"
);
}
if (tools.length > 1) {
throw new Error(
"tool_choice 'required' needs a single tool or specify the tool name explicitly"
);
}
return {
type: "function",
function: { name: tools[0].function.name },
};
}
if ("name" in toolChoice) {
return {
type: "function",
function: { name: toolChoice.name },
};
}
return toolChoice;
};
const resolveApiUrl = () =>
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
: "https://forge.manus.im/v1/chat/completions";
const assertApiKey = () => {
if (!ENV.forgeApiKey) {
throw new Error("OPENAI_API_KEY is not configured");
}
};
const normalizeResponseFormat = ({
responseFormat,
response_format,
outputSchema,
output_schema,
}: {
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
}):
| { type: "json_schema"; json_schema: JsonSchema }
| { type: "text" }
| { type: "json_object" }
| undefined => {
const explicitFormat = responseFormat || response_format;
if (explicitFormat) {
if (
explicitFormat.type === "json_schema" &&
!explicitFormat.json_schema?.schema
) {
throw new Error(
"responseFormat json_schema requires a defined schema object"
);
}
return explicitFormat;
}
const schema = outputSchema || output_schema;
if (!schema) return undefined;
if (!schema.name || !schema.schema) {
throw new Error("outputSchema requires both name and schema");
}
return {
type: "json_schema",
json_schema: {
name: schema.name,
schema: schema.schema,
...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}),
},
};
};
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
assertApiKey();
const {
messages,
tools,
toolChoice,
tool_choice,
outputSchema,
output_schema,
responseFormat,
response_format,
} = params;
const payload: Record<string, unknown> = {
model: "gemini-2.5-flash",
messages: messages.map(normalizeMessage),
};
if (tools && tools.length > 0) {
payload.tools = tools;
}
const normalizedToolChoice = normalizeToolChoice(
toolChoice || tool_choice,
tools
);
if (normalizedToolChoice) {
payload.tool_choice = normalizedToolChoice;
}
payload.max_tokens = 32768
payload.thinking = {
"budget_tokens": 128
}
const normalizedResponseFormat = normalizeResponseFormat({
responseFormat,
response_format,
outputSchema,
output_schema,
});
if (normalizedResponseFormat) {
payload.response_format = normalizedResponseFormat;
}
const response = await fetch(resolveApiUrl(), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`LLM invoke failed: ${response.status} ${response.statusText} ${errorText}`
);
}
return (await response.json()) as InvokeResult;
}

View File

@@ -0,0 +1,102 @@
import { Router } from "express";
import bcrypt from "bcryptjs";
import { getDb } from "../db";
import { users } from "../../drizzle/schema";
import { eq } from "drizzle-orm";
import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
const router = Router();
/**
* Route d'authentification locale avec username/password
* POST /api/auth/local/login
* Body: { username: string, password: string }
*/
router.post("/login", async (req, res) => {
try {
const { username, password } = req.body;
// Validation des champs
if (!username || !password) {
return res.status(400).json({
success: false,
message: "Identifiant et mot de passe requis",
});
}
// Récupérer l'utilisateur par username
const db = await getDb();
if (!db) {
return res.status(500).json({
success: false,
message: "Erreur de connexion à la base de données",
});
}
const result = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (result.length === 0) {
return res.status(401).json({
success: false,
message: "Identifiant ou mot de passe incorrect",
});
}
const user = result[0];
// Vérifier que l'utilisateur a un mot de passe défini
if (!user.password) {
return res.status(401).json({
success: false,
message: "Cet utilisateur n'a pas de mot de passe défini",
});
}
// Comparer le mot de passe
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: "Identifiant ou mot de passe incorrect",
});
}
// Mettre à jour la date de dernière connexion
await db
.update(users)
.set({ lastSignedIn: new Date() })
.where(eq(users.id, user.id));
// Créer le token de session (compatible avec le système OAuth)
const token = await sdk.createSessionToken(user.openId, {
name: user.name || "",
expiresInMs: 7 * 24 * 60 * 60 * 1000, // 7 jours
});
// Définir le cookie de session
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, token, cookieOptions);
// Retourner l'utilisateur (sans le mot de passe)
const { password: _, ...userWithoutPassword } = user;
return res.json({
success: true,
user: userWithoutPassword,
});
} catch (error) {
console.error("[LocalAuth] Error during login:", error);
return res.status(500).json({
success: false,
message: "Erreur lors de la connexion",
});
}
});
export default router;

View File

@@ -0,0 +1,319 @@
/**
* Google Maps API Integration for Manus WebDev Templates
*
* Main function: makeRequest<T>(endpoint, params) - Makes authenticated requests to Google Maps APIs
* All credentials are automatically injected. Array parameters use | as separator.
*
* See API examples below the type definitions for usage patterns.
*/
import { ENV } from "./env";
// ============================================================================
// Configuration
// ============================================================================
type MapsConfig = {
baseUrl: string;
apiKey: string;
};
function getMapsConfig(): MapsConfig {
const baseUrl = ENV.forgeApiUrl;
const apiKey = ENV.forgeApiKey;
if (!baseUrl || !apiKey) {
throw new Error(
"Google Maps proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
);
}
return {
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
};
}
// ============================================================================
// Core Request Handler
// ============================================================================
interface RequestOptions {
method?: "GET" | "POST";
body?: Record<string, unknown>;
}
/**
* Make authenticated requests to Google Maps APIs
*
* @param endpoint - The API endpoint (e.g., "/maps/api/geocode/json")
* @param params - Query parameters for the request
* @param options - Additional request options
* @returns The API response
*/
export async function makeRequest<T = unknown>(
endpoint: string,
params: Record<string, unknown> = {},
options: RequestOptions = {}
): Promise<T> {
const { baseUrl, apiKey } = getMapsConfig();
// Construct full URL: baseUrl + /v1/maps/proxy + endpoint
const url = new URL(`${baseUrl}/v1/maps/proxy${endpoint}`);
// Add API key as query parameter (standard Google Maps API authentication)
url.searchParams.append("key", apiKey);
// Add other query parameters
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
const response = await fetch(url.toString(), {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Google Maps API request failed (${response.status} ${response.statusText}): ${errorText}`
);
}
return (await response.json()) as T;
}
// ============================================================================
// Type Definitions
// ============================================================================
export type TravelMode = "driving" | "walking" | "bicycling" | "transit";
export type MapType = "roadmap" | "satellite" | "terrain" | "hybrid";
export type SpeedUnit = "KPH" | "MPH";
export type LatLng = {
lat: number;
lng: number;
};
export type DirectionsResult = {
routes: Array<{
legs: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
start_address: string;
end_address: string;
start_location: LatLng;
end_location: LatLng;
steps: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
html_instructions: string;
travel_mode: string;
start_location: LatLng;
end_location: LatLng;
}>;
}>;
overview_polyline: { points: string };
summary: string;
warnings: string[];
waypoint_order: number[];
}>;
status: string;
};
export type DistanceMatrixResult = {
rows: Array<{
elements: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
status: string;
}>;
}>;
origin_addresses: string[];
destination_addresses: string[];
status: string;
};
export type GeocodingResult = {
results: Array<{
address_components: Array<{
long_name: string;
short_name: string;
types: string[];
}>;
formatted_address: string;
geometry: {
location: LatLng;
location_type: string;
viewport: {
northeast: LatLng;
southwest: LatLng;
};
};
place_id: string;
types: string[];
}>;
status: string;
};
export type PlacesSearchResult = {
results: Array<{
place_id: string;
name: string;
formatted_address: string;
geometry: {
location: LatLng;
};
rating?: number;
user_ratings_total?: number;
business_status?: string;
types: string[];
}>;
status: string;
};
export type PlaceDetailsResult = {
result: {
place_id: string;
name: string;
formatted_address: string;
formatted_phone_number?: string;
international_phone_number?: string;
website?: string;
rating?: number;
user_ratings_total?: number;
reviews?: Array<{
author_name: string;
rating: number;
text: string;
time: number;
}>;
opening_hours?: {
open_now: boolean;
weekday_text: string[];
};
geometry: {
location: LatLng;
};
};
status: string;
};
export type ElevationResult = {
results: Array<{
elevation: number;
location: LatLng;
resolution: number;
}>;
status: string;
};
export type TimeZoneResult = {
dstOffset: number;
rawOffset: number;
status: string;
timeZoneId: string;
timeZoneName: string;
};
export type RoadsResult = {
snappedPoints: Array<{
location: LatLng;
originalIndex?: number;
placeId: string;
}>;
};
// ============================================================================
// Google Maps API Reference
// ============================================================================
/**
* GEOCODING - Convert between addresses and coordinates
* Endpoint: /maps/api/geocode/json
* Input: { address: string } OR { latlng: string } // latlng: "37.42,-122.08"
* Output: GeocodingResult // results[0].geometry.location, results[0].formatted_address
*/
/**
* DIRECTIONS - Get navigation routes between locations
* Endpoint: /maps/api/directions/json
* Input: { origin: string, destination: string, mode?: TravelMode, waypoints?: string, alternatives?: boolean }
* Output: DirectionsResult // routes[0].legs[0].distance, duration, steps
*/
/**
* DISTANCE MATRIX - Calculate travel times/distances for multiple origin-destination pairs
* Endpoint: /maps/api/distancematrix/json
* Input: { origins: string, destinations: string, mode?: TravelMode, units?: "metric"|"imperial" } // origins: "NYC|Boston"
* Output: DistanceMatrixResult // rows[0].elements[1] = first origin to second destination
*/
/**
* PLACE SEARCH - Find businesses/POIs by text query
* Endpoint: /maps/api/place/textsearch/json
* Input: { query: string, location?: string, radius?: number, type?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult // results[].name, rating, geometry.location, place_id
*/
/**
* NEARBY SEARCH - Find places near a specific location
* Endpoint: /maps/api/place/nearbysearch/json
* Input: { location: string, radius: number, type?: string, keyword?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult
*/
/**
* PLACE DETAILS - Get comprehensive information about a specific place
* Endpoint: /maps/api/place/details/json
* Input: { place_id: string, fields?: string } // fields: "name,rating,opening_hours,website"
* Output: PlaceDetailsResult // result.name, rating, opening_hours, etc.
*/
/**
* ELEVATION - Get altitude data for geographic points
* Endpoint: /maps/api/elevation/json
* Input: { locations?: string, path?: string, samples?: number } // locations: "39.73,-104.98|36.45,-116.86"
* Output: ElevationResult // results[].elevation (meters)
*/
/**
* TIME ZONE - Get timezone information for a location
* Endpoint: /maps/api/timezone/json
* Input: { location: string, timestamp: number } // timestamp: Math.floor(Date.now()/1000)
* Output: TimeZoneResult // timeZoneId, timeZoneName
*/
/**
* ROADS - Snap GPS traces to roads, find nearest roads, get speed limits
* - /v1/snapToRoads: Input: { path: string, interpolate?: boolean } // path: "lat,lng|lat,lng"
* - /v1/nearestRoads: Input: { points: string } // points: "lat,lng|lat,lng"
* - /v1/speedLimits: Input: { path: string, units?: SpeedUnit }
* Output: RoadsResult
*/
/**
* PLACE AUTOCOMPLETE - Real-time place suggestions as user types
* Endpoint: /maps/api/place/autocomplete/json
* Input: { input: string, location?: string, radius?: number }
* Output: { predictions: Array<{ description: string, place_id: string }> }
*/
/**
* STATIC MAPS - Generate map images as URLs (for emails, reports, <img> tags)
* Endpoint: /maps/api/staticmap
* Input: URL params - center: string, zoom: number, size: string, markers?: string, maptype?: MapType
* Output: Image URL (not JSON) - use directly in <img src={url} />
* Note: Construct URL manually with getMapsConfig() for auth
*/

View File

@@ -0,0 +1,114 @@
import { TRPCError } from "@trpc/server";
import { ENV } from "./env";
export type NotificationPayload = {
title: string;
content: string;
};
const TITLE_MAX_LENGTH = 1200;
const CONTENT_MAX_LENGTH = 20000;
const trimValue = (value: string): string => value.trim();
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
const buildEndpointUrl = (baseUrl: string): string => {
const normalizedBase = baseUrl.endsWith("/")
? baseUrl
: `${baseUrl}/`;
return new URL(
"webdevtoken.v1.WebDevService/SendNotification",
normalizedBase
).toString();
};
const validatePayload = (input: NotificationPayload): NotificationPayload => {
if (!isNonEmptyString(input.title)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification title is required.",
});
}
if (!isNonEmptyString(input.content)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification content is required.",
});
}
const title = trimValue(input.title);
const content = trimValue(input.content);
if (title.length > TITLE_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
});
}
if (content.length > CONTENT_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
});
}
return { title, content };
};
/**
* Dispatches a project-owner notification through the Manus Notification Service.
* Returns `true` if the request was accepted, `false` when the upstream service
* cannot be reached (callers can fall back to email/slack). Validation errors
* bubble up as TRPC errors so callers can fix the payload.
*/
export async function notifyOwner(
payload: NotificationPayload
): Promise<boolean> {
const { title, content } = validatePayload(payload);
if (!ENV.forgeApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Notification service URL is not configured.",
});
}
if (!ENV.forgeApiKey) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Notification service API key is not configured.",
});
}
const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
"content-type": "application/json",
"connect-protocol-version": "1",
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.warn(
`[Notification] Failed to notify owner (${response.status} ${response.statusText})${
detail ? `: ${detail}` : ""
}`
);
return false;
}
return true;
} catch (error) {
console.warn("[Notification] Error calling notification service:", error);
return false;
}
}

View File

@@ -0,0 +1,53 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
function getQueryParam(req: Request, key: string): string | undefined {
const value = req.query[key];
return typeof value === "string" ? value : undefined;
}
export function registerOAuthRoutes(app: Express) {
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
if (!userInfo.openId) {
res.status(400).json({ error: "openId missing from user info" });
return;
}
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: new Date(),
});
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.redirect(302, "/");
} catch (error) {
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});
}

View File

@@ -0,0 +1,330 @@
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import type {
ExchangeTokenRequest,
ExchangeTokenResponse,
GetUserInfoResponse,
GetUserInfoWithJwtRequest,
GetUserInfoWithJwtResponse,
} from "./types/manusTypes";
// Utility function
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
export type SessionPayload = {
openId: string;
appId: string;
name: string;
};
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
class OAuthService {
constructor(private client: ReturnType<typeof axios.create>) {
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
if (!ENV.oAuthServerUrl) {
console.error(
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
);
}
}
private decodeState(state: string): string {
const redirectUri = atob(state);
return redirectUri;
}
async getTokenByCode(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
const payload: ExchangeTokenRequest = {
clientId: ENV.appId,
grantType: "authorization_code",
code,
redirectUri: this.decodeState(state),
};
const { data } = await this.client.post<ExchangeTokenResponse>(
EXCHANGE_TOKEN_PATH,
payload
);
return data;
}
async getUserInfoByToken(
token: ExchangeTokenResponse
): Promise<GetUserInfoResponse> {
const { data } = await this.client.post<GetUserInfoResponse>(
GET_USER_INFO_PATH,
{
accessToken: token.accessToken,
}
);
return data;
}
}
const createOAuthHttpClient = (): AxiosInstance =>
axios.create({
baseURL: ENV.oAuthServerUrl,
timeout: AXIOS_TIMEOUT_MS,
});
class SDKServer {
private readonly client: AxiosInstance;
private readonly oauthService: OAuthService;
constructor(client: AxiosInstance = createOAuthHttpClient()) {
this.client = client;
this.oauthService = new OAuthService(this.client);
}
private deriveLoginMethod(
platforms: unknown,
fallback: string | null | undefined
): string | null {
if (fallback && fallback.length > 0) return fallback;
if (!Array.isArray(platforms) || platforms.length === 0) return null;
const set = new Set<string>(
platforms.filter((p): p is string => typeof p === "string")
);
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
if (
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
set.has("REGISTERED_PLATFORM_AZURE")
)
return "microsoft";
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
const first = Array.from(set)[0];
return first ? first.toLowerCase() : null;
}
/**
* Exchange OAuth authorization code for access token
* @example
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
*/
async exchangeCodeForToken(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
return this.oauthService.getTokenByCode(code, state);
}
/**
* Get user information using access token
* @example
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
*/
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
const data = await this.oauthService.getUserInfoByToken({
accessToken,
} as ExchangeTokenResponse);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoResponse;
}
private parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) {
return new Map<string, string>();
}
const parsed = parseCookieHeader(cookieHeader);
return new Map(Object.entries(parsed));
}
private getSessionSecret() {
const secret = ENV.cookieSecret;
return new TextEncoder().encode(secret);
}
/**
* Create a session token for a Manus user openId
* @example
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
*/
async createSessionToken(
openId: string,
options: { expiresInMs?: number; name?: string } = {}
): Promise<string> {
return this.signSession(
{
openId,
appId: ENV.appId,
name: options.name || "",
},
options
);
}
async signSession(
payload: SessionPayload,
options: { expiresInMs?: number } = {}
): Promise<string> {
const issuedAt = Date.now();
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
const secretKey = this.getSessionSecret();
return new SignJWT({
openId: payload.openId,
appId: payload.appId,
name: payload.name,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
.sign(secretKey);
}
async verifySession(
cookieValue: string | undefined | null
): Promise<{ openId: string; appId: string; name: string } | null> {
if (!cookieValue) {
console.warn("[Auth] Missing session cookie");
return null;
}
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
const { openId, appId, name } = payload as Record<string, unknown>;
if (
!isNonEmptyString(openId) ||
!isNonEmptyString(appId) ||
!isNonEmptyString(name)
) {
console.warn("[Auth] Session payload missing required fields");
return null;
}
return {
openId,
appId,
name,
};
} catch (error) {
console.warn("[Auth] Session verification failed", String(error));
return null;
}
}
async getUserInfoWithJwt(
jwtToken: string
): Promise<GetUserInfoWithJwtResponse> {
const payload: GetUserInfoWithJwtRequest = {
jwtToken,
projectId: ENV.appId,
};
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
GET_USER_INFO_WITH_JWT_PATH,
payload
);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoWithJwtResponse;
}
async authenticateRequest(req: Request): Promise<User> {
// Regular authentication flow
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = cookies.get(COOKIE_NAME);
// Try to verify as local JWT first
let session: { openId: string; appId?: string; name: string } | null = null;
let isLocalAuth = false;
if (sessionCookie) {
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(sessionCookie, secretKey, {
algorithms: ["HS256"],
});
const { openId, name, appId } = payload as Record<string, unknown>;
// Check if it's a local JWT (has openId but may not have appId)
if (isNonEmptyString(openId) && isNonEmptyString(name)) {
session = { openId, name, appId: appId as string | undefined };
isLocalAuth = !appId; // Local auth doesn't have appId
}
} catch (error) {
console.warn("[Auth] JWT verification failed:", error);
}
}
// If local JWT verification failed, try OAuth session
if (!session) {
session = await this.verifySession(sessionCookie);
}
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const sessionUserId = session.openId;
const signedInAt = new Date();
let user = await db.getUserByOpenId(sessionUserId);
// If user not in DB, sync from OAuth server automatically (only for OAuth sessions)
if (!user && !isLocalAuth) {
try {
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);
} catch (error) {
console.error("[Auth] Failed to sync user from OAuth:", error);
throw ForbiddenError("Failed to sync user info");
}
}
if (!user) {
throw ForbiddenError("User not found");
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: signedInAt,
});
return user;
}
}
export const sdk = new SDKServer();

View File

@@ -0,0 +1,103 @@
/**
* Service d'envoi d'emails via SMTP avec nodemailer
*/
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
interface EmailParams {
to: string;
subject: string;
html: string;
attachments?: Array<{
filename: string;
content: string;
contentType: string;
}>;
}
interface SMTPConfig {
host: string;
port: number;
secure: 'none' | 'tls' | 'ssl';
user: string;
password: string;
fromEmail: string;
fromName: string;
}
/**
* Crée un transporteur nodemailer à partir de la configuration SMTP
*/
function createTransporter(config: SMTPConfig): Transporter {
const secure = config.secure === 'ssl'; // true pour SSL (port 465), false pour TLS/STARTTLS
return nodemailer.createTransport({
host: config.host,
port: config.port,
secure: secure,
auth: {
user: config.user,
pass: config.password,
},
// Options supplémentaires pour améliorer la compatibilité
tls: {
// Ne pas échouer sur les certificats invalides (à utiliser avec précaution en production)
rejectUnauthorized: false,
},
});
}
/**
* Envoie un email via SMTP
*/
export async function sendViaSMTP(params: EmailParams, config: SMTPConfig): Promise<boolean> {
try {
const transporter = createTransporter(config);
// Préparer les pièces jointes
const attachments = params.attachments?.map(att => ({
filename: att.filename,
content: att.content,
contentType: att.contentType,
}));
// Envoyer l'email
const info = await transporter.sendMail({
from: `${config.fromName} <${config.fromEmail}>`,
to: params.to,
subject: params.subject,
html: params.html,
attachments: attachments,
});
console.log('[Email] Email envoyé via SMTP:', info.messageId);
return true;
} catch (error) {
console.error('[Email] Erreur lors de l\'envoi via SMTP:', error);
return false;
}
}
/**
* Teste la connexion SMTP
*/
export async function testSMTPConnection(config: SMTPConfig): Promise<{ success: boolean; message: string }> {
try {
const transporter = createTransporter(config);
// Vérifier la connexion
await transporter.verify();
return {
success: true,
message: 'Connexion SMTP réussie',
};
} catch (error) {
console.error('[Email] Erreur de connexion SMTP:', error);
return {
success: false,
message: error instanceof Error ? error.message : 'Erreur de connexion SMTP',
};
}
}

View File

@@ -0,0 +1,29 @@
import { z } from "zod";
import { notifyOwner } from "./notification";
import { adminProcedure, publicProcedure, router } from "./trpc";
export const systemRouter = router({
health: publicProcedure
.input(
z.object({
timestamp: z.number().min(0, "timestamp cannot be negative"),
})
)
.query(() => ({
ok: true,
})),
notifyOwner: adminProcedure
.input(
z.object({
title: z.string().min(1, "title is required"),
content: z.string().min(1, "content is required"),
})
)
.mutation(async ({ input }) => {
const delivered = await notifyOwner(input);
return {
success: delivered,
} as const;
}),
});

View File

@@ -0,0 +1,45 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
}),
);

View File

@@ -0,0 +1,6 @@
declare module "cookie" {
export function parse(
str: string,
options?: Record<string, unknown>
): Record<string, string>;
}

View File

@@ -0,0 +1,69 @@
// WebDev Auth TypeScript types
// Auto-generated from protobuf definitions
// Generated on: 2025-09-24T05:57:57.338Z
export interface AuthorizeRequest {
redirectUri: string;
projectId: string;
state: string;
responseType: string;
scope: string;
}
export interface AuthorizeResponse {
redirectUrl: string;
}
export interface ExchangeTokenRequest {
grantType: string;
code: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}
export interface ExchangeTokenResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
refreshToken?: string;
scope: string;
idToken: string;
}
export interface GetUserInfoRequest {
accessToken: string;
}
export interface GetUserInfoResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}
export interface CanAccessRequest {
openId: string;
projectId: string;
}
export interface CanAccessResponse {
canAccess: boolean;
}
export interface GetUserInfoWithJwtRequest {
jwtToken: string;
projectId: string;
}
export interface GetUserInfoWithJwtResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}

View File

@@ -0,0 +1,67 @@
import { Router } from "express";
import multer from "multer";
import { storagePut } from "../storage";
import { randomBytes } from "crypto";
import path from "path";
const router = Router();
// Configuration de multer pour gérer les uploads en mémoire
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
});
/**
* Route d'upload de fichiers vers S3
* POST /api/upload-file
* Body: multipart/form-data avec un champ "file"
*/
router.post("/", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: "Aucun fichier fourni",
});
}
const file = req.file;
// Générer un nom de fichier unique
const randomSuffix = randomBytes(8).toString("hex");
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_");
const fileName = `rappels-attachments/${Date.now()}-${randomSuffix}-${safeBaseName}${ext}`;
// Upload vers S3
const result = await storagePut(
fileName,
file.buffer,
file.mimetype
);
console.log(`[UploadFile] Fichier uploadé: ${file.originalname} (${file.size} octets)`);
return res.json({
success: true,
url: result.url,
key: fileName,
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
message: "Fichier uploadé avec succès",
});
} catch (error) {
console.error("[UploadFile] Error:", error);
return res.status(500).json({
success: false,
message: "Erreur lors de l'upload du fichier",
});
}
});
export default router;

View File

@@ -0,0 +1,67 @@
import { Router } from "express";
import multer from "multer";
import { storagePut } from "../storage";
import { randomBytes } from "crypto";
const router = Router();
// Configuration de multer pour gérer les uploads en mémoire
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
},
fileFilter: (req, file, cb) => {
// Vérifier que c'est bien une image
if (file.mimetype.startsWith("image/")) {
cb(null, true);
} else {
cb(new Error("Le fichier doit être une image"));
}
},
});
/**
* Route d'upload d'images vers S3
* POST /api/upload-image
* Body: multipart/form-data avec un champ "file"
*/
router.post("/", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
message: "Aucun fichier fourni",
});
}
const file = req.file;
// Générer un nom de fichier unique
const randomSuffix = randomBytes(8).toString("hex");
const extension = file.originalname.split(".").pop() || "jpg";
const fileName = `attestations/${Date.now()}-${randomSuffix}.${extension}`;
// Upload vers S3
const result = await storagePut(
fileName,
file.buffer,
file.mimetype
);
return res.json({
success: true,
url: result.url,
s3Key: fileName,
message: "Image uploadée avec succès",
});
} catch (error) {
console.error("[UploadImage] Error:", error);
return res.status(500).json({
success: false,
message: "Erreur lors de l'upload de l'image",
});
}
});
export default router;

View File

@@ -0,0 +1,67 @@
import express, { type Express } from "express";
import fs from "fs";
import { type Server } from "http";
import { nanoid } from "nanoid";
import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"../..",
"client",
"index.html"
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath =
process.env.NODE_ENV === "development"
? path.resolve(import.meta.dirname, "../..", "dist", "public")
: path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
console.error(
`Could not find the build directory: ${distPath}, make sure to build the client first`
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}

View File

@@ -0,0 +1,284 @@
/**
* Voice transcription helper using internal Speech-to-Text service
*
* Frontend implementation guide:
* 1. Capture audio using MediaRecorder API
* 2. Upload audio to storage (e.g., S3) to get URL
* 3. Call transcription with the URL
*
* Example usage:
* ```tsx
* // Frontend component
* const transcribeMutation = trpc.voice.transcribe.useMutation({
* onSuccess: (data) => {
* console.log(data.text); // Full transcription
* console.log(data.language); // Detected language
* console.log(data.segments); // Timestamped segments
* }
* });
*
* // After uploading audio to storage
* transcribeMutation.mutate({
* audioUrl: uploadedAudioUrl,
* language: 'en', // optional
* prompt: 'Transcribe the meeting' // optional
* });
* ```
*/
import { ENV } from "./env";
export type TranscribeOptions = {
audioUrl: string; // URL to the audio file (e.g., S3 URL)
language?: string; // Optional: specify language code (e.g., "en", "es", "zh")
prompt?: string; // Optional: custom prompt for the transcription
};
// Native Whisper API segment format
export type WhisperSegment = {
id: number;
seek: number;
start: number;
end: number;
text: string;
tokens: number[];
temperature: number;
avg_logprob: number;
compression_ratio: number;
no_speech_prob: number;
};
// Native Whisper API response format
export type WhisperResponse = {
task: "transcribe";
language: string;
duration: number;
text: string;
segments: WhisperSegment[];
};
export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly
export type TranscriptionError = {
error: string;
code: "FILE_TOO_LARGE" | "INVALID_FORMAT" | "TRANSCRIPTION_FAILED" | "UPLOAD_FAILED" | "SERVICE_ERROR";
details?: string;
};
/**
* Transcribe audio to text using the internal Speech-to-Text service
*
* @param options - Audio data and metadata
* @returns Transcription result or error
*/
export async function transcribeAudio(
options: TranscribeOptions
): Promise<TranscriptionResponse | TranscriptionError> {
try {
// Step 1: Validate environment configuration
if (!ENV.forgeApiUrl) {
return {
error: "Voice transcription service is not configured",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_URL is not set"
};
}
if (!ENV.forgeApiKey) {
return {
error: "Voice transcription service authentication is missing",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_KEY is not set"
};
}
// Step 2: Download audio from URL
let audioBuffer: Buffer;
let mimeType: string;
try {
const response = await fetch(options.audioUrl);
if (!response.ok) {
return {
error: "Failed to download audio file",
code: "INVALID_FORMAT",
details: `HTTP ${response.status}: ${response.statusText}`
};
}
audioBuffer = Buffer.from(await response.arrayBuffer());
mimeType = response.headers.get('content-type') || 'audio/mpeg';
// Check file size (16MB limit)
const sizeMB = audioBuffer.length / (1024 * 1024);
if (sizeMB > 16) {
return {
error: "Audio file exceeds maximum size limit",
code: "FILE_TOO_LARGE",
details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB`
};
}
} catch (error) {
return {
error: "Failed to fetch audio file",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "Unknown error"
};
}
// Step 3: Create FormData for multipart upload to Whisper API
const formData = new FormData();
// Create a Blob from the buffer and append to form
const filename = `audio.${getFileExtension(mimeType)}`;
const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType });
formData.append("file", audioBlob, filename);
formData.append("model", "whisper-1");
formData.append("response_format", "verbose_json");
// Add prompt - use custom prompt if provided, otherwise generate based on language
const prompt = options.prompt || (
options.language
? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}`
: "Transcribe the user's voice to text"
);
formData.append("prompt", prompt);
// Step 4: Call the transcription service
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"v1/audio/transcriptions",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
authorization: `Bearer ${ENV.forgeApiKey}`,
"Accept-Encoding": "identity",
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
return {
error: "Transcription service request failed",
code: "TRANSCRIPTION_FAILED",
details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}`
};
}
// Step 5: Parse and return the transcription result
const whisperResponse = await response.json() as WhisperResponse;
// Validate response structure
if (!whisperResponse.text || typeof whisperResponse.text !== 'string') {
return {
error: "Invalid transcription response",
code: "SERVICE_ERROR",
details: "Transcription service returned an invalid response format"
};
}
return whisperResponse; // Return native Whisper API response directly
} catch (error) {
// Handle unexpected errors
return {
error: "Voice transcription failed",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "An unexpected error occurred"
};
}
}
/**
* Helper function to get file extension from MIME type
*/
function getFileExtension(mimeType: string): string {
const mimeToExt: Record<string, string> = {
'audio/webm': 'webm',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/ogg': 'ogg',
'audio/m4a': 'm4a',
'audio/mp4': 'm4a',
};
return mimeToExt[mimeType] || 'audio';
}
/**
* Helper function to get full language name from ISO code
*/
function getLanguageName(langCode: string): string {
const langMap: Record<string, string> = {
'en': 'English',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'it': 'Italian',
'pt': 'Portuguese',
'ru': 'Russian',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese',
'ar': 'Arabic',
'hi': 'Hindi',
'nl': 'Dutch',
'pl': 'Polish',
'tr': 'Turkish',
'sv': 'Swedish',
'da': 'Danish',
'no': 'Norwegian',
'fi': 'Finnish',
};
return langMap[langCode] || langCode;
}
/**
* Example tRPC procedure implementation:
*
* ```ts
* // In server/routers.ts
* import { transcribeAudio } from "./_core/voiceTranscription";
*
* export const voiceRouter = router({
* transcribe: protectedProcedure
* .input(z.object({
* audioUrl: z.string(),
* language: z.string().optional(),
* prompt: z.string().optional(),
* }))
* .mutation(async ({ input, ctx }) => {
* const result = await transcribeAudio(input);
*
* // Check if it's an error
* if ('error' in result) {
* throw new TRPCError({
* code: 'BAD_REQUEST',
* message: result.error,
* cause: result,
* });
* }
*
* // Optionally save transcription to database
* await db.insert(transcriptions).values({
* userId: ctx.user.id,
* text: result.text,
* duration: result.duration,
* language: result.language,
* audioUrl: input.audioUrl,
* createdAt: new Date(),
* });
*
* return result;
* }),
* });
* ```
*/

View File

@@ -0,0 +1,147 @@
/**
* Requêtes analytiques pour le tableau de bord
*/
import { eq, sql, and, gte, lte, desc } from "drizzle-orm";
import { getDb } from "./db";
import { inscriptions, sequences, apprenants, datesFormation, formations } from "../drizzle/schema";
/**
* Récupère les statistiques d'inscriptions par mois
* @param startDate - Date de début de la période
* @param endDate - Date de fin de la période
*/
export async function getInscriptionsByMonth(startDate?: Date, endDate?: Date) {
const db = await getDb();
if (!db) return [];
const conditions = [];
if (startDate) {
conditions.push(gte(inscriptions.dateInscription, startDate));
}
if (endDate) {
conditions.push(lte(inscriptions.dateInscription, endDate));
}
const result = await db
.select({
mois: sql<string>`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`,
total: sql<number>`COUNT(*)`,
confirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
listeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
annulees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`,
})
.from(inscriptions)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.groupBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`)
.orderBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`);
return result;
}
/**
* Récupère le taux de remplissage des séquences par mois
*/
export async function getTauxRemplissageByMonth() {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
mois: sql<string>`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`,
capaciteTotale: sql<number>`SUM(${sequences.capaciteMax})`,
inscritsConfirmes: sql<number>`SUM(
(SELECT COUNT(*)
FROM ${inscriptions}
WHERE ${inscriptions.sequenceId} = ${sequences.id}
AND ${inscriptions.statut} = 'confirmee')
)`,
tauxRemplissage: sql<number>`ROUND(
(SUM(
(SELECT COUNT(*)
FROM ${inscriptions}
WHERE ${inscriptions.sequenceId} = ${sequences.id}
AND ${inscriptions.statut} = 'confirmee')
) / SUM(${sequences.capaciteMax})) * 100,
2
)`,
})
.from(sequences)
.groupBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`)
.orderBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`);
return result;
}
/**
* Récupère les statistiques de participation par établissement
*/
export async function getParticipationByEtablissement() {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
codeEtablissement: apprenants.codeEtablissement,
totalApprenants: sql<number>`COUNT(DISTINCT ${apprenants.id})`,
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
inscriptionsConfirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
inscriptionsListeAttente: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`,
inscriptionsAnnulees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`,
})
.from(apprenants)
.leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId))
.groupBy(apprenants.codeEtablissement)
.orderBy(desc(sql<number>`COUNT(${inscriptions.id})`));
return result;
}
/**
* Récupère les statistiques globales du tableau de bord
*/
export async function getGlobalStats() {
const db = await getDb();
if (!db) return null;
const [stats] = await db
.select({
totalFormations: sql<number>`(SELECT COUNT(*) FROM ${formations} WHERE ${formations.actif} = 1)`,
totalSequences: sql<number>`(SELECT COUNT(*) FROM ${sequences})`,
sequencesOuvertes: sql<number>`(SELECT COUNT(*) FROM ${sequences} WHERE ${sequences.statut} = 'ouverte')`,
totalApprenants: sql<number>`(SELECT COUNT(*) FROM ${apprenants})`,
totalInscriptions: sql<number>`(SELECT COUNT(*) FROM ${inscriptions})`,
inscriptionsConfirmees: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee')`,
inscriptionsListeAttente: sql<number>`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'liste_attente')`,
tauxRemplissageMoyen: sql<number>`ROUND(
(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee') /
(SELECT SUM(${sequences.capaciteMax}) FROM ${sequences}) * 100,
2
)`,
})
.from(sql`dual`);
return stats;
}
/**
* Récupère les statistiques de participation par fonction
*/
export async function getParticipationByFonction() {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
fonction: apprenants.fonction,
totalApprenants: sql<number>`COUNT(DISTINCT ${apprenants.id})`,
totalInscriptions: sql<number>`COUNT(${inscriptions.id})`,
inscriptionsConfirmees: sql<number>`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`,
})
.from(apprenants)
.leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId))
.groupBy(apprenants.fonction)
.orderBy(desc(sql<number>`COUNT(${inscriptions.id})`));
return result;
}

View File

@@ -0,0 +1,145 @@
/**
* Service d'export des rapports analytiques en PDF et Excel
*/
import ExcelJS from 'exceljs';
import { getGlobalStats, getInscriptionsByMonth, getTauxRemplissageByMonth, getParticipationByEtablissement, getParticipationByFonction } from './analyticsDb';
/**
* Génère un rapport Excel des statistiques analytiques
*/
export async function generateAnalyticsExcel(startDate?: Date, endDate?: Date): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
// Récupérer toutes les données
const globalStats = await getGlobalStats();
const inscriptionsByMonth = await getInscriptionsByMonth(startDate, endDate);
const tauxRemplissage = await getTauxRemplissageByMonth();
const participationEtablissement = await getParticipationByEtablissement();
const participationFonction = await getParticipationByFonction();
// Feuille 1: Statistiques globales
const statsSheet = workbook.addWorksheet('Statistiques globales');
statsSheet.columns = [
{ header: 'Indicateur', key: 'indicateur', width: 30 },
{ header: 'Valeur', key: 'valeur', width: 15 },
];
if (globalStats) {
statsSheet.addRows([
{ indicateur: 'Total Formations actives', valeur: globalStats.totalFormations },
{ indicateur: 'Total Séquences', valeur: globalStats.totalSequences },
{ indicateur: 'Séquences ouvertes', valeur: globalStats.sequencesOuvertes },
{ indicateur: 'Total Apprenants', valeur: globalStats.totalApprenants },
{ indicateur: 'Total Inscriptions', valeur: globalStats.totalInscriptions },
{ indicateur: 'Inscriptions confirmées', valeur: globalStats.inscriptionsConfirmees },
{ indicateur: 'Inscriptions en liste d\'attente', valeur: globalStats.inscriptionsListeAttente },
{ indicateur: 'Taux de remplissage moyen (%)', valeur: globalStats.tauxRemplissageMoyen },
]);
}
// Feuille 2: Inscriptions par mois
const inscriptionsSheet = workbook.addWorksheet('Inscriptions par mois');
inscriptionsSheet.columns = [
{ header: 'Mois', key: 'mois', width: 15 },
{ header: 'Total', key: 'total', width: 12 },
{ header: 'Confirmées', key: 'confirmees', width: 12 },
{ header: 'Liste d\'attente', key: 'listeAttente', width: 15 },
{ header: 'Annulées', key: 'annulees', width: 12 },
];
inscriptionsByMonth.forEach(item => {
inscriptionsSheet.addRow({
mois: item.mois,
total: Number(item.total),
confirmees: Number(item.confirmees),
listeAttente: Number(item.listeAttente),
annulees: Number(item.annulees),
});
});
// Feuille 3: Taux de remplissage
const tauxSheet = workbook.addWorksheet('Taux de remplissage');
tauxSheet.columns = [
{ header: 'Mois', key: 'mois', width: 15 },
{ header: 'Capacité totale', key: 'capaciteTotale', width: 15 },
{ header: 'Inscrits confirmés', key: 'inscritsConfirmes', width: 18 },
{ header: 'Taux de remplissage (%)', key: 'tauxRemplissage', width: 20 },
];
tauxRemplissage.forEach(item => {
tauxSheet.addRow({
mois: item.mois,
capaciteTotale: Number(item.capaciteTotale),
inscritsConfirmes: Number(item.inscritsConfirmes),
tauxRemplissage: Number(item.tauxRemplissage),
});
});
// Feuille 4: Participation par établissement
const etablissementSheet = workbook.addWorksheet('Participation par établissement');
etablissementSheet.columns = [
{ header: 'Code établissement', key: 'codeEtablissement', width: 20 },
{ header: 'Total apprenants', key: 'totalApprenants', width: 15 },
{ header: 'Total inscriptions', key: 'totalInscriptions', width: 18 },
{ header: 'Confirmées', key: 'confirmees', width: 12 },
{ header: 'Liste d\'attente', key: 'listeAttente', width: 15 },
{ header: 'Annulées', key: 'annulees', width: 12 },
];
participationEtablissement.forEach(item => {
etablissementSheet.addRow({
codeEtablissement: item.codeEtablissement,
totalApprenants: Number(item.totalApprenants),
totalInscriptions: Number(item.totalInscriptions),
confirmees: Number(item.inscriptionsConfirmees),
listeAttente: Number(item.inscriptionsListeAttente),
annulees: Number(item.inscriptionsAnnulees),
});
});
// Feuille 5: Participation par fonction
const fonctionSheet = workbook.addWorksheet('Participation par fonction');
fonctionSheet.columns = [
{ header: 'Fonction', key: 'fonction', width: 20 },
{ header: 'Total apprenants', key: 'totalApprenants', width: 15 },
{ header: 'Total inscriptions', key: 'totalInscriptions', width: 18 },
{ header: 'Confirmées', key: 'confirmees', width: 12 },
];
participationFonction.forEach(item => {
fonctionSheet.addRow({
fonction: item.fonction === 'directeur' ? 'Directeurs' :
item.fonction === 'chef_service' ? 'Chefs de service' : 'Autres',
totalApprenants: Number(item.totalApprenants),
totalInscriptions: Number(item.totalInscriptions),
confirmees: Number(item.inscriptionsConfirmees),
});
});
// Styliser les en-têtes
[statsSheet, inscriptionsSheet, tauxSheet, etablissementSheet, fonctionSheet].forEach(sheet => {
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF3B82F6' },
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
});
// Générer le buffer
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Génère un rapport PDF des statistiques analytiques
* Note: Cette fonction retourne actuellement un placeholder
* Une implémentation complète nécessiterait une bibliothèque comme puppeteer ou pdfkit
*/
export async function generateAnalyticsPDF(startDate?: Date, endDate?: Date): Promise<Buffer> {
// Pour l'instant, retourner un message indiquant que la fonctionnalité est en développement
// Une implémentation complète nécessiterait de générer un HTML et de le convertir en PDF
throw new Error("L'export PDF sera implémenté dans une prochaine version");
}

View File

@@ -0,0 +1,430 @@
import { getDb } from "./db";
import { attestations, configAttestation, inscriptions, apprenants, sequences, formations, datesFormation } from "../drizzle/schema";
import { eq, and } from "drizzle-orm";
import { storagePut } from "./storage";
import PDFDocument from "pdfkit";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import crypto from "crypto";
/**
* Génère une attestation de formation en PDF
*/
export async function genererAttestationPDF(inscriptionId: number): Promise<{ s3Key: string; pdfUrl: string }> {
const db = await getDb();
if (!db) {
throw new Error("Base de données non disponible");
}
// Récupérer les données de l'inscription
const [inscriptionData] = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
sequence: sequences,
formation: formations,
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.where(eq(inscriptions.id, inscriptionId))
.limit(1);
if (!inscriptionData) {
throw new Error("Inscription introuvable");
}
const { inscription, apprenant, sequence, formation } = inscriptionData;
// Récupérer les dates de la séquence
const dates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequence.id));
// Récupérer la configuration de l'attestation
const [config] = await db
.select()
.from(configAttestation)
.limit(1);
// Créer le PDF
const doc = new PDFDocument({
size: "A4",
margins: { top: 50, bottom: 50, left: 50, right: 50 },
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
return new Promise((resolve, reject) => {
doc.on("end", async () => {
try {
const pdfBuffer = Buffer.concat(chunks);
// Générer une clé S3 unique
const randomSuffix = crypto.randomBytes(8).toString("hex");
const s3Key = `attestations/${apprenant.id}-${sequence.id}-${randomSuffix}.pdf`;
// Upload vers S3
const { url } = await storagePut(s3Key, pdfBuffer, "application/pdf");
resolve({ s3Key, pdfUrl: url });
} catch (error) {
reject(error);
}
});
doc.on("error", reject);
// Construction du PDF
(async () => {
try {
// Logo en haut à droite si disponible
if (config?.logoUrl) {
try {
const logoResponse = await fetch(config.logoUrl);
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
doc.image(logoBuffer, doc.page.width - 150, 50, { width: 100 });
} catch (error) {
console.error("Erreur lors du chargement du logo:", error);
}
}
// Titre
doc.fontSize(24).font("Helvetica-Bold").text("ATTESTATION DE FORMATION", { align: "center" });
doc.moveDown(2);
// Texte personnalisable ou texte par défaut
const texteAttestation = config?.texteAttestation ||
"Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
// Remplacer les variables
const nomComplet = `${apprenant.prenom} ${apprenant.nom}`;
const dateDebut = dates.length > 0 ? format(new Date(dates[0].dateDebut), "dd MMMM yyyy", { locale: fr }) : "";
const dateFin = dates.length > 0 ? format(new Date(dates[dates.length - 1].dateFin), "dd MMMM yyyy", { locale: fr }) : "";
const texteRempli = texteAttestation
.replace(/\{\{nomComplet\}\}/g, nomComplet)
.replace(/\{\{nomFormation\}\}/g, formation.nom)
.replace(/\{\{dateDebut\}\}/g, dateDebut)
.replace(/\{\{dateFin\}\}/g, dateFin);
doc.fontSize(12).font("Helvetica").text(texteRempli, { align: "justify" });
doc.moveDown(2);
// Détails de la formation
doc.fontSize(10).font("Helvetica-Bold").text("Détails de la formation :", { underline: true });
doc.moveDown(0.5);
doc.font("Helvetica");
doc.text(`Formation : ${formation.nom}`);
doc.text(`Séquence : ${sequence.nom}`);
doc.text(`Lieu : ${sequence.lieu}`);
doc.moveDown(0.5);
doc.text("Dates :");
dates.forEach((date) => {
const dateStr = format(new Date(date.dateDebut), "dd/MM/yyyy", { locale: fr });
const heureDebut = format(new Date(date.dateDebut), "HH:mm");
const heureFin = format(new Date(date.dateFin), "HH:mm");
doc.text(`${dateStr} de ${heureDebut} à ${heureFin}`);
});
doc.moveDown(3);
// Signature
doc.fontSize(10);
doc.text(`Fait le ${format(new Date(), "dd MMMM yyyy", { locale: fr })}`, { align: "right" });
doc.moveDown(3);
// Signature si disponible
if (config?.signatureUrl) {
try {
const signatureResponse = await fetch(config.signatureUrl);
const signatureBuffer = Buffer.from(await signatureResponse.arrayBuffer());
const signatureX = doc.page.width - 200;
const signatureY = doc.y;
doc.image(signatureBuffer, signatureX, signatureY, { width: 150, height: 50 });
doc.moveDown(3);
} catch (error) {
console.error("Erreur lors du chargement de la signature:", error);
}
}
if (config?.nomSignataire) {
doc.text(config.nomSignataire, { align: "right" });
}
if (config?.fonctionSignataire) {
doc.text(config.fonctionSignataire, { align: "right" });
}
doc.end();
} catch (error) {
doc.end();
reject(error);
}
})();
});
}
/**
* Enregistre une attestation dans la base de données
*/
export async function enregistrerAttestation(
inscriptionId: number,
apprenantId: number,
sequenceId: number,
s3Key: string,
pdfUrl: string
): Promise<number> {
const db = await getDb();
if (!db) {
throw new Error("Base de données non disponible");
}
const [result] = await db.insert(attestations).values({
inscriptionId,
apprenantId,
sequenceId,
s3Key,
pdfUrl,
emailEnvoye: false,
});
return result.insertId;
}
/**
* Récupère l'attestation d'un apprenant pour une séquence
*/
export async function getAttestation(apprenantId: number, sequenceId: number) {
const db = await getDb();
if (!db) return null;
const [attestation] = await db
.select()
.from(attestations)
.where(
and(
eq(attestations.apprenantId, apprenantId),
eq(attestations.sequenceId, sequenceId)
)
)
.limit(1);
return attestation || null;
}
/**
* Récupère toutes les attestations d'un apprenant
*/
export async function getAttestationsApprenant(apprenantId: number) {
const db = await getDb();
if (!db) return [];
return db
.select({
attestation: attestations,
sequence: sequences,
formation: formations,
})
.from(attestations)
.innerJoin(sequences, eq(attestations.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.where(eq(attestations.apprenantId, apprenantId));
}
/**
* Marque une attestation comme envoyée par email
*/
export async function marquerAttestationEnvoyee(attestationId: number) {
const db = await getDb();
if (!db) return;
await db
.update(attestations)
.set({
emailEnvoye: true,
dateEnvoiEmail: new Date(),
})
.where(eq(attestations.id, attestationId));
}
/**
* Récupère ou crée la configuration des attestations
*/
export async function getOrCreateConfigAttestation() {
const db = await getDb();
if (!db) return null;
let [config] = await db.select().from(configAttestation).limit(1);
if (!config) {
// Créer une configuration par défaut
await db.insert(configAttestation).values({
texteAttestation: "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.",
});
[config] = await db.select().from(configAttestation).limit(1);
}
return config;
}
/**
* Met à jour la configuration des attestations
*/
export async function updateConfigAttestation(data: {
logoS3Key?: string;
logoUrl?: string;
signatureS3Key?: string;
signatureUrl?: string;
nomSignataire?: string;
fonctionSignataire?: string;
texteAttestation?: string;
}) {
const db = await getDb();
if (!db) return;
const [existing] = await db.select().from(configAttestation).limit(1);
if (existing) {
await db.update(configAttestation).set(data).where(eq(configAttestation.id, existing.id));
} else {
await db.insert(configAttestation).values(data);
}
}
/**
* Génère une prévisualisation du modèle d'attestation avec des données fictives
*/
export async function genererPreviewAttestation(): Promise<{ pdfUrl: string }> {
const db = await getDb();
if (!db) {
throw new Error("Base de données non disponible");
}
// Récupérer la configuration de l'attestation
const config = await getOrCreateConfigAttestation();
if (!config) {
throw new Error("Configuration d'attestation introuvable");
}
// Données fictives pour la prévisualisation
const donneesFictives = {
apprenant: {
nom: "Dupont",
prenom: "Jean",
email: "jean.dupont@example.com",
},
formation: {
nom: "Formation Manager Itinova",
nbJours: 5,
},
sequence: {
nom: "Groupe A",
lieu: "Paris",
},
dates: [
{ date: new Date("2026-02-10") },
{ date: new Date("2026-02-11") },
{ date: new Date("2026-02-12") },
{ date: new Date("2026-02-13") },
{ date: new Date("2026-02-14") },
],
};
// Créer le PDF
const doc = new PDFDocument({ size: "A4", margin: 50 });
const chunks: Buffer[] = [];
doc.on("data", (chunk) => chunks.push(chunk));
// En-tête avec logo si disponible
if (config.logoUrl) {
try {
const response = await fetch(config.logoUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
doc.image(buffer, 50, 45, { width: 100 });
} catch (error) {
console.error("Erreur lors du chargement du logo:", error);
}
}
// Titre
doc
.fontSize(24)
.font("Helvetica-Bold")
.text("ATTESTATION DE FORMATION", 0, 150, { align: "center" });
doc.moveDown(2);
// Texte de l'attestation avec remplacement des variables
let texte = config.texteAttestation || "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.";
const dateDebut = format(donneesFictives.dates[0].date, "d MMMM yyyy", { locale: fr });
const dateFin = format(donneesFictives.dates[donneesFictives.dates.length - 1].date, "d MMMM yyyy", { locale: fr });
texte = texte
.replace(/\{\{nomComplet\}\}/g, `${donneesFictives.apprenant.prenom} ${donneesFictives.apprenant.nom}`)
.replace(/\{\{nomFormation\}\}/g, donneesFictives.formation.nom)
.replace(/\{\{dateDebut\}\}/g, dateDebut)
.replace(/\{\{dateFin\}\}/g, dateFin)
.replace(/\{\{lieu\}\}/g, donneesFictives.sequence.lieu)
.replace(/\{\{nbJours\}\}/g, donneesFictives.formation.nbJours.toString());
doc
.fontSize(12)
.font("Helvetica")
.text(texte, { align: "justify", lineGap: 5 });
doc.moveDown(2);
// Dates de formation
doc.fontSize(10).font("Helvetica-Bold").text("Dates de formation :");
doc.font("Helvetica");
donneesFictives.dates.forEach((d) => {
doc.text(`${format(d.date, "EEEE d MMMM yyyy", { locale: fr })}`, { indent: 20 });
});
doc.moveDown(2);
// Signature
doc.fontSize(10).font("Helvetica").text(`Fait à ${donneesFictives.sequence.lieu}, le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`);
doc.moveDown(1);
if (config.signatureUrl) {
try {
const response = await fetch(config.signatureUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
doc.image(buffer, doc.x, doc.y, { width: 150 });
doc.moveDown(3);
} catch (error) {
console.error("Erreur lors du chargement de la signature:", error);
}
}
if (config.nomSignataire) {
doc.font("Helvetica-Bold").text(config.nomSignataire);
}
if (config.fonctionSignataire) {
doc.font("Helvetica").text(config.fonctionSignataire);
}
// Finaliser le PDF
doc.end();
await new Promise((resolve) => doc.on("end", resolve));
const pdfBuffer = Buffer.concat(chunks);
// Uploader sur S3
const randomSuffix = crypto.randomBytes(8).toString("hex");
const s3Key = `attestations/preview/preview-${randomSuffix}.pdf`;
const { url: pdfUrl } = await storagePut(s3Key, pdfBuffer, "application/pdf");
return { pdfUrl };
}

View File

@@ -0,0 +1,93 @@
import { desc, eq } from "drizzle-orm";
import { getDb } from "./db";
import { historiqueAttestations, sequences, apprenants, formations } from "../drizzle/schema";
/**
* Enregistrer un envoi d'attestation dans l'historique
*/
export async function enregistrerEnvoiAttestation(data: {
sequenceId: number;
apprenantId: number;
statut: "envoye" | "erreur";
messageErreur?: string;
urlAttestation?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(historiqueAttestations).values({
sequenceId: data.sequenceId,
apprenantId: data.apprenantId,
statut: data.statut,
messageErreur: data.messageErreur || null,
urlAttestation: data.urlAttestation || null,
});
}
/**
* Récupérer l'historique des envois d'attestations
* Avec les informations de la séquence, formation et apprenant
*/
export async function getHistoriqueAttestations() {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
sequence: {
id: sequences.id,
nom: sequences.nom,
},
formation: {
id: formations.id,
nom: formations.nom,
},
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(sequences, eq(historiqueAttestations.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}
/**
* Récupérer l'historique des attestations pour une séquence spécifique
*/
export async function getHistoriqueAttestationsBySequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: historiqueAttestations.id,
dateEnvoi: historiqueAttestations.dateEnvoi,
statut: historiqueAttestations.statut,
messageErreur: historiqueAttestations.messageErreur,
urlAttestation: historiqueAttestations.urlAttestation,
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
})
.from(historiqueAttestations)
.innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id))
.where(eq(historiqueAttestations.sequenceId, sequenceId))
.orderBy(desc(historiqueAttestations.dateEnvoi));
return results;
}

View 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}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
/**
* Génération d'aperçus d'emails avec données réelles
*/
import * as db from "./db";
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
export interface EmailPreviewParams {
sequenceId: number;
type: 'teaser' | 'rappel' | 'rappel_j1';
apprenantId?: number; // Si non fourni, prendre le premier apprenant inscrit
}
export async function generateEmailPreview(params: EmailPreviewParams): Promise<{
html: string;
subject: string;
recipient: {
email: string;
nom: string;
prenom: string;
};
}> {
// Récupérer la séquence
const sequence = await db.getSequenceById(params.sequenceId);
if (!sequence) {
throw new Error('Séquence non trouvée');
}
// Récupérer la formation
const formation = await db.getFormationById(sequence.formationId);
if (!formation) {
throw new Error('Formation non trouvée');
}
// Récupérer les dates
const dates = await db.getDatesBySequence(sequence.id);
if (dates.length === 0) {
throw new Error('Aucune date trouvée pour cette séquence');
}
// Récupérer le formateur si disponible
let formateurNom: string | undefined;
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
formateurNom = formateur?.nom;
}
// Récupérer un apprenant inscrit
const inscriptions = await db.getInscriptionsBySequence(params.sequenceId);
const confirmedInscriptions = inscriptions.filter(i => i.inscription.statut === 'confirmee' && i.apprenant);
if (confirmedInscriptions.length === 0) {
throw new Error('Aucun apprenant confirmé trouvé pour cette séquence');
}
// Utiliser l'apprenant spécifié ou le premier de la liste
let selectedInscription = confirmedInscriptions[0];
if (params.apprenantId) {
const found = confirmedInscriptions.find(i => i.apprenant!.id === params.apprenantId);
if (found) {
selectedInscription = found;
}
}
const apprenant = selectedInscription.apprenant!;
// Préparer les données pour l'email
const fonctionLabel = apprenant.fonction === 'directeur' ? 'Directeur' :
apprenant.fonction === 'chef_service' ? 'Chef de service' : '';
const salutation = fonctionLabel ? `${fonctionLabel} ${apprenant.prenom} ${apprenant.nom}` : apprenant.prenom;
const datesHTML = dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong> ${new Date(date.dateDebut).toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
`).join('');
// Générer le contenu selon le type
let content: string;
let subject: string;
if (params.type === 'teaser') {
content = `
<h2>Votre formation approche !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${formation.nom}</strong>.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</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>
`;
subject = `Votre formation ${formation.nom} approche !`;
} else if (params.type === 'rappel') {
// Rappel J-7
content = `
<h2>Rappel : Votre formation commence bientôt !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence dans une semaine.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</div>
<p>N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.</p>
<p>À très bientôt !</p>
`;
subject = `Rappel J-7 : Formation ${formation.nom}`;
} else {
// Rappel J-1
content = `
<h2>Rappel : Votre formation commence demain !</h2>
<p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${formation.nom}</strong> commence <strong>demain</strong>.</p>
<div class="info-box">
<p><strong>Séquence :</strong> ${sequence.nom}</p>
<h4>Dates :</h4>
${datesHTML}
${sequence.lieu ? `<p><strong>Lieu :</strong> ${sequence.lieu}</p>` : ''}
${formateurNom ? `<p><strong>Formateur :</strong> ${formateurNom}</p>` : ''}
</div>
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
<p>N'oubliez pas d'apporter le matériel nécessaire.</p>
<p>À demain !</p>
`;
subject = `Rappel J-1 : Formation ${formation.nom} - C'est demain !`;
}
// Préparer les variables
const variables = {
nomApprenant: apprenant.nom,
prenomApprenant: apprenant.prenom,
nomFormation: formation.nom,
nomSequence: sequence.nom,
dateDebut: new Date(dates[0].dateDebut).toLocaleDateString('fr-FR'),
dateFin: new Date(dates[dates.length - 1].dateFin).toLocaleDateString('fr-FR'),
datesHTML: datesHTML, // Ajouter le HTML des dates
lieu: sequence.lieu || '',
formateur: formateurNom || '',
};
// Générer le HTML final avec le template
const html = await generateEmailFromTemplate(params.type === 'teaser' ? 'teaser' : 'rappel', content, variables);
return {
html,
subject,
recipient: {
email: apprenant.email,
nom: apprenant.nom,
prenom: apprenant.prenom,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
/**
* 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";
import { replaceEmailVariables, EmailVariables } from "./emailTemplateUtils";
/**
* Génère le HTML d'un email en utilisant le template personnalisé
* @param templateType - Type de template (à utiliser)
* @param content - Contenu de l'email (legacy, sera remplacé par bodyContent)
* @param variables - Variables à remplacer dans le template
*/
export async function generateEmailFromTemplate(
templateType: string,
content: string,
variables?: EmailVariables
): 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) {
const defaultContent = variables ? replaceEmailVariables(content, variables) : content;
return getDefaultEmailTemplate(defaultContent);
}
return buildEmailHTML(template, content, variables);
}
/**
* Construit le HTML de l'email avec le template
*/
function buildEmailHTML(template: EmailTemplate, content: string, variables?: EmailVariables): string {
// Utiliser bodyContent si disponible, sinon utiliser content (legacy)
let emailBody = template.bodyContent || content;
// Remplacer les variables si fournies
if (variables) {
emailBody = replaceEmailVariables(emailBody, variables);
// Remplacer aussi dans le titre et le pied de page
template = {
...template,
headerTitle: replaceEmailVariables(template.headerTitle, variables),
footerText: template.footerText ? replaceEmailVariables(template.footerText, variables) : template.footerText,
};
}
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">
${emailBody}
</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();
}

View File

@@ -0,0 +1,66 @@
/**
* Utilitaires pour le remplacement des variables dans les templates d'emails
*/
export interface EmailVariables {
nomApprenant?: string;
prenomApprenant?: string;
nomFormation?: string;
nomSequence?: string;
dateDebut?: string;
dateFin?: string;
datesHTML?: string; // HTML formaté de toutes les dates de la séquence
lieu?: string;
formateur?: string;
lienInscription?: string;
[key: string]: string | undefined;
}
/**
* Remplace les variables {{variable}} dans un texte par leurs valeurs
* @param template - Le texte contenant les variables à remplacer
* @param variables - Un objet contenant les valeurs des variables
* @returns Le texte avec les variables remplacées
*/
export function replaceEmailVariables(
template: string,
variables: EmailVariables
): string {
let result = template;
// Remplacer chaque variable trouvée dans le template
Object.keys(variables).forEach((key) => {
const value = variables[key];
if (value !== undefined && value !== null) {
// Remplacer toutes les occurrences de {{key}} par la valeur
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
result = result.replace(regex, value);
}
});
// Nettoyer les variables non remplacées (optionnel)
// result = result.replace(/\{\{[^}]+\}\}/g, '');
return result;
}
/**
* Génère un aperçu d'email avec des données d'exemple
* @param template - Le template d'email
* @returns Le template avec des données d'exemple
*/
export function generateEmailPreview(template: string): string {
const exampleVariables: EmailVariables = {
nomApprenant: 'Dupont',
prenomApprenant: 'Marie',
nomFormation: 'Formation Management',
nomSequence: 'Séquence 1 - Introduction',
dateDebut: '15/01/2025',
dateFin: '17/01/2025',
lieu: 'Salle de formation A - Bâtiment principal',
formateur: 'Jean Martin',
lienInscription: 'https://exemple.com/inscription/abc123',
};
return replaceEmailVariables(template, exampleVariables);
}

View File

@@ -0,0 +1,110 @@
import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { apprenants, inscriptions } from "../drizzle/schema";
export async function getEtablissementsStats() {
const db = await getDb();
if (!db) return [];
// Récupérer tous les apprenants groupés par établissement
const apprenantsData = await db.select().from(apprenants);
// Grouper par code établissement
const etablissementsMap = new Map<string, {
codeEtablissement: string;
nombreApprenants: number;
apprenantsActifs: number;
apprenantIds: number[];
}>();
for (const apprenant of apprenantsData) {
const code = apprenant.codeEtablissement;
if (!etablissementsMap.has(code)) {
etablissementsMap.set(code, {
codeEtablissement: code,
nombreApprenants: 0,
apprenantsActifs: 0,
apprenantIds: [],
});
}
const etab = etablissementsMap.get(code)!;
etab.nombreApprenants++;
etab.apprenantIds.push(apprenant.id);
}
// Pour chaque établissement, compter les apprenants actifs
const etablissementsStats = await Promise.all(
Array.from(etablissementsMap.values()).map(async (etab) => {
// Compter le nombre d'apprenants ayant au moins une inscription
const apprenantsActifsCount = await Promise.all(
etab.apprenantIds.map(async (apprenantId) => {
const inscriptionsData = await db
.select()
.from(inscriptions)
.where(eq(inscriptions.apprenantId, apprenantId))
.limit(1);
return inscriptionsData.length > 0 ? 1 : 0;
})
);
const nombreActifs = apprenantsActifsCount.reduce((sum: number, count) => sum + count, 0);
const tauxParticipation = etab.nombreApprenants > 0
? Math.round((nombreActifs / etab.nombreApprenants) * 100)
: 0;
return {
codeEtablissement: etab.codeEtablissement,
nombreApprenants: etab.nombreApprenants,
apprenantsActifs: nombreActifs,
tauxParticipation,
};
})
);
// Trier par nombre d'apprenants décroissant
return etablissementsStats.sort((a, b) => b.nombreApprenants - a.nombreApprenants);
}
export async function getEtablissementDetail(codeEtablissement: string) {
const db = await getDb();
if (!db) return null;
// Récupérer tous les apprenants de cet établissement
const apprenantsData = await db
.select()
.from(apprenants)
.where(eq(apprenants.codeEtablissement, codeEtablissement));
// Pour chaque apprenant, récupérer ses inscriptions
const apprenantsWithInscriptions = await Promise.all(
apprenantsData.map(async (apprenant) => {
const inscriptionsData = await db
.select()
.from(inscriptions)
.where(eq(inscriptions.apprenantId, apprenant.id));
return {
...apprenant,
inscriptions: inscriptionsData,
};
})
);
const nombreApprenants = apprenantsData.length;
const apprenantsActifs = apprenantsWithInscriptions.filter(
(a) => a.inscriptions.length > 0
).length;
const tauxParticipation = nombreApprenants > 0
? Math.round((apprenantsActifs / nombreApprenants) * 100)
: 0;
return {
codeEtablissement,
nombreApprenants,
apprenantsActifs,
tauxParticipation,
apprenants: apprenantsWithInscriptions,
};
}

View File

@@ -0,0 +1,277 @@
/**
* Service d'export des données en Excel et PDF
*/
import * as XLSX from 'xlsx';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as fs from 'fs';
import * as path from 'path';
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;
formateur?: 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 ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Inscrits');
// Ajouter une feuille d'informations
const infoData = [
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
];
// Ajouter les dates
sequenceInfo.dates.forEach(date => {
infoData.push({
'Information': `Date ${date.ordre}`,
'Valeur': date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
});
});
const wsInfo = XLSX.utils.json_to_sheet(infoData);
XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations');
// Générer le buffer
return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }));
}
/**
* Génère un 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' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 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;
});
// 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 avec signatures matin/après-midi pour chaque journée
*/
export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
const doc = new jsPDF();
// Charger le logo
let logoData: string | null = null;
try {
// Utiliser un chemin absolu depuis la racine du projet
const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png');
const logoBuffer = fs.readFileSync(logoPath);
logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`;
} catch (error) {
console.warn('Logo Itinova non trouvé, génération sans logo:', error);
}
// Fonction pour générer une page de feuille de présence pour une journée
const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
if (!isFirstPage) {
doc.addPage();
}
// Ajouter le logo en haut à droite si disponible
if (logoData) {
doc.addImage(logoData, 'PNG', 160, 10, 40, 15);
}
// 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;
// Ajouter le formateur si disponible
if (feuilleInfo.formateur) {
doc.text(`Formateur : ${feuilleInfo.formateur}`, 14, yPos);
yPos += 6;
}
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
yPos += 8;
// Date de la journée
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
doc.setFont('helvetica', 'normal');
yPos += 10;
// Tableau de présence avec signatures matin et après-midi
const tableData = feuilleInfo.apprenants.map(i => [
i.nom,
i.prenom,
i.codeEtablissement,
i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre',
'', // Signature matin
'', // Signature après-midi
]);
autoTable(doc, {
startY: yPos,
head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']],
body: tableData,
styles: {
fontSize: 9,
cellPadding: 3,
minCellHeight: 10,
},
headStyles: {
fillColor: [37, 99, 235],
halign: 'center',
},
columnStyles: {
0: { cellWidth: 30 }, // Nom
1: { cellWidth: 30 }, // Prénom
2: { cellWidth: 25 }, // Code établissement
3: { cellWidth: 30 }, // Fonction
4: { cellWidth: 35 }, // Signature matin
5: { cellWidth: 35 }, // Signature après-midi
},
});
// Ajouter un champ de signature pour le formateur en bas de page
const finalY = (doc as any).lastAutoTable.finalY || yPos + 50;
const signatureY = finalY + 20;
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Signature du formateur :', 14, signatureY);
doc.setFont('helvetica', 'normal');
// Dessiner une ligne pour la signature
doc.line(60, signatureY, 120, signatureY);
// Ajouter la date
doc.setFontSize(9);
doc.text('Date : _______________', 140, signatureY);
};
// Générer une page pour chaque date
feuilleInfo.dates.forEach((date, index) => {
generatePageForDate(date, index === 0);
});
return Buffer.from(doc.output('arraybuffer'));
}

View File

@@ -0,0 +1,402 @@
import { eq, sql, and, gte, lte, desc } from "drizzle-orm";
import { getDb } from "./db";
import {
sequences,
formations,
formateurs,
datesFormation,
inscriptions,
apprenants,
supportsFormation
} from "../drizzle/schema";
/**
* Récupère le calendrier des interventions d'un formateur
* @param formateurId - ID du formateur
* @param dateDebut - Date de début optionnelle
* @param dateFin - Date de fin optionnelle
*/
export async function getCalendrierFormateur(formateurId: number, dateDebut?: Date, dateFin?: Date) {
const db = await getDb();
if (!db) return [];
const conditions = [eq(sequences.formateurId, formateurId)];
if (dateDebut) {
conditions.push(gte(datesFormation.dateDebut, dateDebut));
}
if (dateFin) {
conditions.push(lte(datesFormation.dateFin, dateFin));
}
const results = await db
.select({
sequenceId: sequences.id,
sequenceNom: sequences.nom,
formationId: formations.id,
formationNom: formations.nom,
dateId: datesFormation.id,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
ordre: datesFormation.ordre,
lieu: sequences.lieu,
nbInscrits: sql<number>`(
SELECT COUNT(*)
FROM inscriptions
WHERE inscriptions.sequenceId = ${sequences.id}
AND inscriptions.statut IN ('confirmee', 'liste_attente')
)`,
})
.from(sequences)
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(datesFormation.dateDebut);
return results;
}
/**
* Récupère la liste des apprenants inscrits à une séquence
* @param sequenceId - ID de la séquence
*/
export async function getApprenantsSequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
inscriptionId: inscriptions.id,
apprenantId: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
fonction: apprenants.fonction,
codeEtablissement: apprenants.codeEtablissement,
statut: inscriptions.statut,
dateInscription: inscriptions.dateInscription,
// presenceValidee n'existe pas dans le schéma actuel
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(eq(inscriptions.sequenceId, sequenceId))
.orderBy(apprenants.nom, apprenants.prenom);
return results;
}
/**
* Récupère les supports de formation d'une séquence
* @param sequenceId - ID de la séquence
*/
export async function getSupportsSequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
id: supportsFormation.id,
nomFichier: supportsFormation.nomFichier,
typeFichier: supportsFormation.typeFichier,
tailleFichier: supportsFormation.tailleFichier,
urlFichier: supportsFormation.urlFichier,
description: supportsFormation.description,
formateurNom: formateurs.nom,
createdAt: supportsFormation.createdAt,
})
.from(supportsFormation)
.innerJoin(formateurs, eq(supportsFormation.formateurId, formateurs.id))
.where(eq(supportsFormation.sequenceId, sequenceId))
.orderBy(desc(supportsFormation.createdAt));
return results;
}
/**
* Ajoute un support de formation
* @param support - Données du support à ajouter
*/
export async function ajouterSupport(support: {
sequenceId: number;
formateurId: number;
nomFichier: string;
typeFichier: string;
tailleFichier: number;
urlFichier: string;
s3Key: string;
description?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [result] = await db.insert(supportsFormation).values(support);
return result;
}
/**
* Supprime un support de formation
* @param supportId - ID du support à supprimer
* @param formateurId - ID du formateur (pour vérification)
*/
export async function supprimerSupport(supportId: number, formateurId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que le support appartient bien au formateur
const [support] = await db
.select()
.from(supportsFormation)
.where(
and(
eq(supportsFormation.id, supportId),
eq(supportsFormation.formateurId, formateurId)
)
)
.limit(1);
if (!support) {
throw new Error("Support non trouvé ou non autorisé");
}
await db.delete(supportsFormation).where(eq(supportsFormation.id, supportId));
return support; // Retourner le support pour récupérer la clé S3
}
/**
* Valide la présence d'un apprenant
* @param inscriptionId - ID de l'inscription
* @param present - true si présent, false sinon
*/
export async function validerPresence(inscriptionId: number, present: boolean) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.update(inscriptions)
.set({ statut: present ? 'confirmee' : 'annulee' })
.where(eq(inscriptions.id, inscriptionId));
}
/**
* Récupère l'historique des formations d'un formateur
* @param formateurId - ID du formateur
* @param limit - Nombre de résultats à retourner (par défaut 50)
*/
export async function getHistoriqueFormateur(formateurId: number, limit: number = 50) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
sequenceId: sequences.id,
sequenceNom: sequences.nom,
formationNom: formations.nom,
dateDebut: sql<Date>`MIN(${datesFormation.dateDebut})`,
dateFin: sql<Date>`MAX(${datesFormation.dateFin})`,
lieu: sequences.lieu,
nbInscrits: sql<number>`(
SELECT COUNT(*)
FROM inscriptions
WHERE inscriptions.sequenceId = ${sequences.id}
AND inscriptions.statut IN ('confirmee', 'liste_attente')
)`,
nbPresents: sql<number>`(
SELECT COUNT(*)
FROM inscriptions
WHERE inscriptions.sequenceId = ${sequences.id}
AND inscriptions.statut = 'confirmee'
)`,
})
.from(sequences)
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
.where(eq(sequences.formateurId, formateurId))
.groupBy(sequences.id, sequences.nom, formations.nom, sequences.lieu)
.orderBy(desc(sql`MIN(${datesFormation.dateDebut})`))
.limit(limit);
return results;
}
/**
* Récupère les détails d'une séquence pour un formateur
* @param sequenceId - ID de la séquence
* @param formateurId - ID du formateur (pour vérification)
*/
export async function getDetailSequence(sequenceId: number, formateurId: number) {
const db = await getDb();
if (!db) return null;
const [result] = await db
.select({
id: sequences.id,
nom: sequences.nom,
formationNom: formations.nom,
lieu: sequences.lieu,
capaciteMax: sequences.capaciteMax,
formateurNom: formateurs.nom,
})
.from(sequences)
.innerJoin(formations, eq(sequences.formationId, formations.id))
.innerJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
eq(sequences.id, sequenceId),
eq(sequences.formateurId, formateurId)
)
)
.limit(1);
if (!result) return null;
// Récupérer les dates de formation
const dates = await db
.select({
id: datesFormation.id,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
ordre: datesFormation.ordre,
})
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequenceId))
.orderBy(datesFormation.dateDebut);
return {
...result,
dates,
};
}
/**
* Récupérer les statistiques du tableau de bord pour un formateur
*/
export async function getFormateurDashboardStats(formateurId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Nombre total de séquences du formateur
const totalSequences = await db
.select({ count: sql<number>`COUNT(*)` })
.from(sequences)
.where(eq(sequences.formateurId, formateurId));
// Nombre total d'inscrits confirmés à toutes les séquences du formateur
const totalInscrits = await db
.select({ count: sql<number>`COUNT(*)` })
.from(inscriptions)
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.where(
and(
eq(sequences.formateurId, formateurId),
eq(inscriptions.statut, 'confirmee')
)
);
// Nombre de séquences à venir (ayant au moins une date future)
const today = new Date();
const sequencesAvenir = await db
.select({ sequenceId: datesFormation.sequenceId })
.from(datesFormation)
.innerJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.where(
and(
eq(sequences.formateurId, formateurId),
gte(datesFormation.dateDebut, today)
)
)
.groupBy(datesFormation.sequenceId);
return {
totalSequences: totalSequences[0]?.count || 0,
totalInscrits: totalInscrits[0]?.count || 0,
sequencesAvenir: sequencesAvenir.length,
};
}
/**
* Récupérer les prochaines séquences du formateur avec leurs détails
*/
export async function getFormateurProchainesSequences(formateurId: number, limit: number = 10) {
const db = await getDb();
if (!db) return [];
const today = new Date();
// Récupérer les séquences du formateur ayant au moins une date future
const seqs = await db
.select()
.from(sequences)
.where(eq(sequences.formateurId, formateurId));
// Pour chaque séquence, récupérer les détails
const sequencesAvecDetails = await Promise.all(
seqs.map(async (seq) => {
// Récupérer toutes les dates de la séquence
const dates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, seq.id))
.orderBy(datesFormation.dateDebut);
// Vérifier si au moins une date est future
const hasFutureDate = dates.some(d => new Date(d.dateDebut) >= today);
if (!hasFutureDate) return null;
// Récupérer la formation
const formation = await db
.select()
.from(formations)
.where(eq(formations.id, seq.formationId))
.limit(1);
// Compter les inscrits confirmés
const inscritsCount = await db
.select({ count: sql<number>`COUNT(*)` })
.from(inscriptions)
.where(
and(
eq(inscriptions.sequenceId, seq.id),
eq(inscriptions.statut, 'confirmee')
)
);
return {
...seq,
formation: formation[0] || null,
dates,
nbInscrits: inscritsCount[0]?.count || 0,
prochaineDateDebut: dates.find(d => new Date(d.dateDebut) >= today)?.dateDebut || dates[0]?.dateDebut,
};
})
);
// Filtrer les séquences nulles et trier par prochaine date
const sequencesFiltrees = sequencesAvecDetails
.filter(s => s !== null)
.sort((a, b) => {
const dateA = new Date(a!.prochaineDateDebut);
const dateB = new Date(b!.prochaineDateDebut);
return dateA.getTime() - dateB.getTime();
})
.slice(0, limit);
return sequencesFiltrees;
}
/**
* Récupérer l'email du formateur à partir de son ID
*/
export async function getFormateurEmailById(formateurId: number): Promise<string | null> {
const db = await getDb();
if (!db) return null;
const result = await db
.select({ email: formateurs.email })
.from(formateurs)
.where(eq(formateurs.id, formateurId))
.limit(1);
return result[0]?.email || null;
}

View File

@@ -0,0 +1,202 @@
import { eq, and, desc, sql } from "drizzle-orm";
import { getDb } from "./db";
import { formations, attestations, inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema";
/**
* Récupérer la configuration d'une formation
*/
export async function getFormationConfig(formationId: number) {
const db = await getDb();
if (!db) return null;
const results = await db
.select({
id: formations.id,
nom: formations.nom,
modeAttestation: formations.modeAttestation,
modeEnvoi: formations.modeEnvoi,
})
.from(formations)
.where(eq(formations.id, formationId))
.limit(1);
return results.length > 0 ? results[0] : null;
}
/**
* Mettre à jour la configuration d'une formation
*/
export async function updateFormationConfig(
formationId: number,
modeAttestation: "auto" | "manuel",
modeEnvoi: "auto" | "manuel"
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.update(formations)
.set({
modeAttestation,
modeEnvoi,
updatedAt: new Date(),
})
.where(eq(formations.id, formationId));
return true;
}
/**
* Récupérer toutes les attestations d'une formation avec les informations des apprenants
*/
export async function getAttestationsByFormation(formationId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
attestation: attestations,
apprenant: apprenants,
sequence: sequences,
})
.from(attestations)
.innerJoin(inscriptions, eq(attestations.inscriptionId, inscriptions.id))
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.where(eq(sequences.formationId, formationId))
.orderBy(desc(attestations.createdAt));
return results;
}
/**
* Uploader un document d'attestation pour un apprenant
*/
export async function uploadAttestationDocument(
inscriptionId: number,
documentUrl: string,
documentS3Key: string,
uploadedBy: number
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier si une attestation existe déjà pour cette inscription
const existing = await db
.select()
.from(attestations)
.where(eq(attestations.inscriptionId, inscriptionId))
.limit(1);
if (existing.length > 0) {
// Mettre à jour l'attestation existante
await db
.update(attestations)
.set({
documentUrl,
documentS3Key,
uploadedBy,
uploadedAt: new Date(),
})
.where(eq(attestations.inscriptionId, inscriptionId));
return existing[0].id;
} else {
// Créer une nouvelle attestation
const result = await db
.insert(attestations)
.values({
inscriptionId,
documentUrl,
documentS3Key,
uploadedBy,
uploadedAt: new Date(),
})
.$returningId();
return result[0].id;
}
}
/**
* Envoyer une attestation par email
*/
export async function markAttestationAsSent(attestationId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.update(attestations)
.set({
emailEnvoye: true,
dateEnvoiEmail: new Date(),
})
.where(eq(attestations.id, attestationId));
return true;
}
/**
* Récupérer les apprenants d'une formation avec leur statut d'attestation
*/
export async function getApprenantsWithAttestationStatus(formationId: number) {
const db = await getDb();
if (!db) return [];
const results = await db
.select({
apprenant: {
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
email: apprenants.email,
},
inscription: {
id: inscriptions.id,
sequenceId: inscriptions.sequenceId,
},
sequence: {
id: sequences.id,
nom: sequences.nom,
},
attestation: {
id: attestations.id,
documentUrl: attestations.documentUrl,
emailEnvoye: attestations.emailEnvoye,
dateEnvoiEmail: attestations.dateEnvoiEmail,
},
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.leftJoin(attestations, eq(attestations.inscriptionId, inscriptions.id))
.where(
and(
eq(sequences.formationId, formationId),
eq(inscriptions.statut, "confirmee")
)
)
.orderBy(apprenants.nom, apprenants.prenom);
return results;
}
/**
* Supprimer un document d'attestation
*/
export async function deleteAttestationDocument(attestationId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.update(attestations)
.set({
documentUrl: null,
documentS3Key: null,
uploadedBy: null,
uploadedAt: null,
})
.where(eq(attestations.id, attestationId));
return true;
}

View 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}`,
});
}

View File

@@ -0,0 +1,331 @@
import * as XLSX from 'xlsx';
import { getDb } from './db';
import { formations as formationsTable, sequences as sequencesTable, datesFormation } from '../drizzle/schema';
export interface FormationImport {
nom: string;
description: string;
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
}
export interface SequenceImport {
formationNom: string;
nom: string;
lieu: string;
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
capaciteMax: number;
dateBlocage: string;
statut: 'ouverte' | 'bloquee' | 'terminee';
dates: Array<{ debut: string; fin: string }>;
}
export interface ImportResult {
success: boolean;
formationsCreated: number;
sequencesCreated: number;
errors: string[];
warnings: string[];
}
/**
* Parse un fichier Excel et retourne les données structurées
*/
export function parseExcelFile(buffer: Buffer): { formations: FormationImport[]; sequences: SequenceImport[] } {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const formations: FormationImport[] = [];
const sequences: SequenceImport[] = [];
// Parser la feuille "Formations"
if (workbook.SheetNames.includes('Formations')) {
const sheet = workbook.Sheets['Formations'];
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
// Ignorer la première ligne (en-têtes) et les lignes vides
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row || row.length === 0 || !row[0]) continue;
// Arrêter si on atteint les instructions
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
formations.push({
nom: String(row[0] || '').trim(),
description: String(row[1] || '').trim(),
publicCible: normalizePublicCible(String(row[2] || '').trim()),
});
}
}
// Parser la feuille "Séquences"
if (workbook.SheetNames.includes('Séquences')) {
const sheet = workbook.Sheets['Séquences'];
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (!row || row.length === 0 || !row[0]) continue;
// Arrêter si on atteint les instructions
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
const dates: Array<{ debut: string; fin: string }> = [];
// Parser les 4 dates possibles (colonnes 7-14)
for (let d = 0; d < 4; d++) {
const debutIdx = 7 + (d * 2);
const finIdx = 8 + (d * 2);
if (row[debutIdx] && row[finIdx]) {
dates.push({
debut: parseExcelDate(row[debutIdx]),
fin: parseExcelDate(row[finIdx]),
});
}
}
sequences.push({
formationNom: String(row[0] || '').trim(),
nom: String(row[1] || '').trim(),
lieu: String(row[2] || '').trim(),
publicCible: normalizePublicCible(String(row[3] || '').trim()),
capaciteMax: parseInt(String(row[4] || '12')),
dateBlocage: parseExcelDate(row[5]),
statut: normalizeStatut(String(row[6] || '').trim()),
dates,
});
}
}
return { formations, sequences };
}
/**
* Valide les données importées
*/
export function validateImportData(
formations: FormationImport[],
sequences: SequenceImport[]
): { valid: boolean; errors: string[]; warnings: string[] } {
const errors: string[] = [];
const warnings: string[] = [];
// Valider les formations
formations.forEach((formation, index) => {
if (!formation.nom) {
errors.push(`Formation ligne ${index + 2}: Le nom est obligatoire`);
}
if (!formation.description) {
errors.push(`Formation ligne ${index + 2}: La description est obligatoire`);
}
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(formation.publicCible)) {
errors.push(`Formation ligne ${index + 2}: Public cible invalide (${formation.publicCible})`);
}
});
// Créer un set des noms de formations pour validation des séquences
const formationNames = new Set(formations.map(f => f.nom.toLowerCase()));
// Valider les séquences
sequences.forEach((sequence, index) => {
if (!sequence.formationNom) {
errors.push(`Séquence ligne ${index + 2}: Le nom de la formation est obligatoire`);
} else if (!formationNames.has(sequence.formationNom.toLowerCase())) {
errors.push(`Séquence ligne ${index + 2}: Formation "${sequence.formationNom}" non trouvée dans la feuille Formations`);
}
if (!sequence.nom) {
errors.push(`Séquence ligne ${index + 2}: Le nom est obligatoire`);
}
if (!sequence.lieu) {
errors.push(`Séquence ligne ${index + 2}: Le lieu est obligatoire`);
}
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(sequence.publicCible)) {
errors.push(`Séquence ligne ${index + 2}: Public cible invalide`);
}
if (!sequence.dateBlocage) {
errors.push(`Séquence ligne ${index + 2}: La date de blocage est obligatoire`);
}
if (!['ouverte', 'bloquee', 'terminee'].includes(sequence.statut)) {
errors.push(`Séquence ligne ${index + 2}: Statut invalide`);
}
if (sequence.dates.length === 0) {
errors.push(`Séquence ligne ${index + 2}: Au moins une date est obligatoire`);
}
if (sequence.dates.length > 4) {
warnings.push(`Séquence ligne ${index + 2}: Maximum 4 dates autorisées, les dates supplémentaires seront ignorées`);
}
});
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Importe les données dans la base de données
*/
export async function importToDatabase(
formations: FormationImport[],
sequences: SequenceImport[]
): Promise<ImportResult> {
const db = await getDb();
if (!db) {
return {
success: false,
formationsCreated: 0,
sequencesCreated: 0,
errors: ['Connexion à la base de données impossible'],
warnings: [],
};
}
const errors: string[] = [];
const warnings: string[] = [];
let formationsCreated = 0;
let sequencesCreated = 0;
try {
// Map pour stocker les IDs des formations créées
const formationIdMap = new Map<string, number>();
// Importer les formations
console.log(`[Import] Début import de ${formations.length} formations`);
for (const formation of formations) {
try {
console.log(`[Import] Création formation: ${formation.nom}`);
const [result] = await db.insert(formationsTable).values({
nom: formation.nom,
description: formation.description,
lienUnique: generateUniqueLien(),
}).$returningId();
const formationId = result.id;
formationIdMap.set(formation.nom.toLowerCase(), formationId);
console.log(`[Import] Formation créée avec ID ${formationId}, ajoutée à la map avec clé: "${formation.nom.toLowerCase()}"`);
formationsCreated++;
} catch (error: any) {
console.error(`[Import] Erreur formation "${formation.nom}":`, error);
errors.push(`Erreur lors de la création de la formation "${formation.nom}": ${error.message}`);
}
}
console.log(`[Import] Formations créées: ${formationsCreated}, Map size: ${formationIdMap.size}`);
console.log(`[Import] Clés dans la map:`, Array.from(formationIdMap.keys()));
// Si des erreurs sont survenues lors de la création des formations, arrêter l'import
if (errors.length > 0) {
console.error(`[Import] Arrêt de l'import car ${errors.length} erreur(s) lors de la création des formations`);
return {
success: false,
formationsCreated,
sequencesCreated: 0,
errors,
warnings,
};
}
// Importer les séquences
console.log(`[Import] Début import de ${sequences.length} séquences`);
for (const sequence of sequences) {
try {
const searchKey = sequence.formationNom.toLowerCase();
console.log(`[Import] Recherche formation pour séquence "${sequence.nom}" avec clé: "${searchKey}"`);
const formationId = formationIdMap.get(searchKey);
if (!formationId) {
errors.push(`Formation "${sequence.formationNom}" non trouvée pour la séquence "${sequence.nom}"`);
continue;
}
const [result] = await db.insert(sequencesTable).values({
formationId,
nom: sequence.nom,
lieu: sequence.lieu,
publicCible: sequence.publicCible,
capaciteMax: sequence.capaciteMax,
dateBlocage: new Date(sequence.dateBlocage),
statut: sequence.statut,
}).$returningId();
const sequenceId = result.id;
// Insérer les dates de formation
for (let i = 0; i < Math.min(sequence.dates.length, 4); i++) {
const date = sequence.dates[i];
await db.insert(datesFormation).values({
sequenceId,
dateDebut: new Date(date.debut),
dateFin: new Date(date.fin),
ordre: i + 1,
});
}
sequencesCreated++;
} catch (error: any) {
errors.push(`Erreur lors de la création de la séquence "${sequence.nom}": ${error.message}`);
}
}
return {
success: errors.length === 0,
formationsCreated,
sequencesCreated,
errors,
warnings,
};
} catch (error: any) {
return {
success: false,
formationsCreated,
sequencesCreated,
errors: [`Erreur générale: ${error.message}`],
warnings,
};
}
}
/**
* Normalise le public cible
*/
function normalizePublicCible(value: string): 'directeur' | 'chef_service' | 'tous' | 'autre' {
const normalized = value.toLowerCase().trim();
if (normalized === 'directeur') return 'directeur';
if (normalized === 'chef_service' || normalized === 'chef de service') return 'chef_service';
if (normalized === 'tous') return 'tous';
return 'autre';
}
/**
* Normalise le statut
*/
function normalizeStatut(value: string): 'ouverte' | 'bloquee' | 'terminee' {
const normalized = value.toLowerCase().trim();
if (normalized === 'ouverte') return 'ouverte';
if (normalized === 'bloquee' || normalized === 'bloquée') return 'bloquee';
if (normalized === 'terminee' || normalized === 'terminée') return 'terminee';
return 'ouverte';
}
/**
* Parse une date Excel (peut être un nombre de série Excel ou une chaîne)
*/
function parseExcelDate(value: any): string {
if (!value) return '';
// Si c'est un nombre (date Excel)
if (typeof value === 'number') {
const date = XLSX.SSF.parse_date_code(value);
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H || 0).padStart(2, '0')}:${String(date.M || 0).padStart(2, '0')}`;
}
// Si c'est déjà une chaîne
return String(value).trim();
}
/**
* Génère un lien unique pour une formation
*/
function generateUniqueLien(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

View File

@@ -0,0 +1,85 @@
import { eq, ne, and } from "drizzle-orm";
import { inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema";
import { getDb } from "./db";
/**
* Récupérer les inscriptions d'une séquence avec les dates de formation
*/
export async function getInscriptionsWithDates(sequenceId: number) {
const db = await getDb();
if (!db) return [];
// Récupérer les inscriptions
const results = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(
and(
eq(inscriptions.sequenceId, sequenceId),
ne(inscriptions.statut, "annulee")
)
);
// Récupérer les dates de formation pour cette séquence
const dates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequenceId))
.orderBy(datesFormation.ordre);
// Combiner les données
return results.map((r) => ({
...r,
dates,
}));
}
/**
* Récupérer les inscriptions d'un apprenant avec les détails des séquences et dates
*/
export async function getInscriptionsByApprenantWithDates(apprenantId: number) {
const db = await getDb();
if (!db) return [];
// Récupérer les inscriptions
const results = await db
.select({
inscription: inscriptions,
sequence: sequences,
})
.from(inscriptions)
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.where(
and(
eq(inscriptions.apprenantId, apprenantId),
ne(inscriptions.statut, "annulee")
)
);
// Pour chaque inscription, récupérer les dates de formation
const resultsWithDates = await Promise.all(
results.map(async (r) => {
if (!r.sequence) return { ...r, dates: [] };
const dates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, r.sequence.id))
.orderBy(datesFormation.ordre);
return {
...r,
sequence: {
...r.sequence,
dates,
},
};
})
);
return resultsWithDates;
}

View File

@@ -0,0 +1,210 @@
import { eq, desc, and, gte, lte, sql, like } from "drizzle-orm";
import { getDb } from "./db";
import { logsNotifications, sequences, apprenants, formateurs, formations } from "../drizzle/schema";
export type NotificationType =
| "remerciement"
| "notification_formateur_inscription"
| "notification_formateur_annulation"
| "alerte_capacite"
| "notification_liste_attente";
export interface LogNotificationInput {
type: NotificationType;
sequenceId?: number;
apprenantId?: number;
formateurId?: number;
emailDestinataire: string;
sujet: string;
statut: "success" | "failed";
messageErreur?: string;
metadata?: Record<string, unknown>;
}
/**
* Enregistre un log de notification
*/
export async function logNotification(input: LogNotificationInput) {
const db = await getDb();
if (!db) {
console.warn("[NotificationLogs] Database not available");
return;
}
try {
await db.insert(logsNotifications).values({
type: input.type,
sequenceId: input.sequenceId || null,
apprenantId: input.apprenantId || null,
formateurId: input.formateurId || null,
emailDestinataire: input.emailDestinataire,
sujet: input.sujet,
statut: input.statut,
messageErreur: input.messageErreur || null,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
});
console.log(`[NotificationLogs] Log créé: ${input.type} -> ${input.emailDestinataire} (${input.statut})`);
} catch (error) {
console.error("[NotificationLogs] Erreur lors de la création du log:", error);
}
}
/**
* Récupère l'historique des notifications avec filtres
*/
export async function getNotificationLogs(filters?: {
type?: NotificationType;
dateDebut?: Date;
dateFin?: Date;
statut?: "success" | "failed";
email?: string;
sequenceId?: number;
limit?: number;
offset?: number;
}) {
const db = await getDb();
if (!db) return { logs: [], total: 0 };
const conditions = [];
if (filters?.type) {
conditions.push(eq(logsNotifications.type, filters.type));
}
if (filters?.dateDebut) {
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
}
if (filters?.dateFin) {
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
}
if (filters?.statut) {
conditions.push(eq(logsNotifications.statut, filters.statut));
}
if (filters?.email) {
conditions.push(like(logsNotifications.emailDestinataire, `%${filters.email}%`));
}
if (filters?.sequenceId) {
conditions.push(eq(logsNotifications.sequenceId, filters.sequenceId));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// Récupérer les logs avec les informations associées
const logs = await db
.select({
id: logsNotifications.id,
type: logsNotifications.type,
sequenceId: logsNotifications.sequenceId,
apprenantId: logsNotifications.apprenantId,
formateurId: logsNotifications.formateurId,
emailDestinataire: logsNotifications.emailDestinataire,
sujet: logsNotifications.sujet,
dateEnvoi: logsNotifications.dateEnvoi,
statut: logsNotifications.statut,
messageErreur: logsNotifications.messageErreur,
metadata: logsNotifications.metadata,
sequenceNom: sequences.nom,
formationNom: formations.nom,
apprenantNom: apprenants.nom,
apprenantPrenom: apprenants.prenom,
formateurNom: formateurs.nom,
})
.from(logsNotifications)
.leftJoin(sequences, eq(logsNotifications.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(apprenants, eq(logsNotifications.apprenantId, apprenants.id))
.leftJoin(formateurs, eq(logsNotifications.formateurId, formateurs.id))
.where(whereClause)
.orderBy(desc(logsNotifications.dateEnvoi))
.limit(filters?.limit || 50)
.offset(filters?.offset || 0);
// Compter le total
const [countResult] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(logsNotifications)
.where(whereClause);
return {
logs,
total: countResult?.count || 0,
};
}
/**
* Récupère les statistiques des notifications
*/
export async function getNotificationStats(filters?: {
dateDebut?: Date;
dateFin?: Date;
}) {
const db = await getDb();
if (!db) return null;
const conditions = [];
if (filters?.dateDebut) {
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
}
if (filters?.dateFin) {
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
// Statistiques globales
const [globalStats] = await db
.select({
total: sql<number>`COUNT(*)`,
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
})
.from(logsNotifications)
.where(whereClause);
// Statistiques par type
const statsByType = await db
.select({
type: logsNotifications.type,
total: sql<number>`COUNT(*)`,
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
})
.from(logsNotifications)
.where(whereClause)
.groupBy(logsNotifications.type);
// Évolution par jour (7 derniers jours)
const evolutionParJour = await db
.select({
date: sql<string>`DATE(dateEnvoi)`,
total: sql<number>`COUNT(*)`,
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
})
.from(logsNotifications)
.where(whereClause)
.groupBy(sql`DATE(dateEnvoi)`)
.orderBy(sql`DATE(dateEnvoi)`)
.limit(30);
return {
global: {
total: globalStats?.total || 0,
success: globalStats?.success || 0,
failed: globalStats?.failed || 0,
tauxSucces: globalStats?.total ? Math.round((globalStats.success / globalStats.total) * 100) : 0,
},
parType: statsByType,
evolutionParJour,
};
}
/**
* Labels pour les types de notifications
*/
export const notificationTypeLabels: Record<NotificationType, string> = {
remerciement: "Remerciement post-formation",
notification_formateur_inscription: "Notification formateur (inscription)",
notification_formateur_annulation: "Notification formateur (annulation)",
alerte_capacite: "Alerte capacité atteinte",
notification_liste_attente: "Notification liste d'attente",
};

View File

@@ -0,0 +1,134 @@
import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { parametres, historiqueParametres } from "../drizzle/schema";
/**
* Récupérer les paramètres de l'application
* S'il n'y a pas de paramètres, en créer un avec les valeurs par défaut
*/
export async function getParametres() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.select().from(parametres).limit(1);
if (result.length === 0) {
// Créer les paramètres par défaut
await db.insert(parametres).values({
urlPublique: "https://formations.itinova.org",
});
const newResult = await db.select().from(parametres).limit(1);
return newResult[0];
}
return result[0];
}
/**
* Mettre à jour les paramètres de l'application
* Enregistre automatiquement l'historique des modifications
*/
export async function updateParametres(
updates: Partial<{
urlPublique: string;
delaiExpirationQR: number;
dureeValiditeToken: number;
notificationsActives: boolean;
envoiAutomatiqueAttestations: boolean;
}>,
userId: number,
userName: string
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const params = await getParametres();
// Enregistrer l'historique pour chaque champ modifié
for (const [key, value] of Object.entries(updates)) {
const oldValue = params[key as keyof typeof params];
if (oldValue !== value) {
await db.insert(historiqueParametres).values({
userId,
userName,
champModifie: key,
ancienneValeur: oldValue !== null && oldValue !== undefined ? String(oldValue) : null,
nouvelleValeur: value !== null && value !== undefined ? String(value) : null,
});
}
}
// Mettre à jour les paramètres
await db.update(parametres)
.set(updates)
.where(eq(parametres.id, params.id));
return getParametres();
}
/**
* Mettre à jour l'URL publique (fonction de compatibilité)
*/
export async function updateUrlPublique(urlPublique: string, userId?: number, userName?: string) {
return updateParametres(
{ urlPublique },
userId || 1,
userName || "Système"
);
}
/**
* Récupérer l'historique des modifications des paramètres
*/
export async function getHistoriqueParametres(limit: number = 50) {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.select()
.from(historiqueParametres)
.orderBy(sql`${historiqueParametres.dateModification} DESC`)
.limit(limit);
}
/**
* Tester l'accessibilité d'une URL
*/
export async function testerUrl(url: string): Promise<{ accessible: boolean; message: string; statusCode?: number }> {
try {
// Vérifier le format de l'URL
const urlObj = new URL(url);
if (!['http:', 'https:'].includes(urlObj.protocol)) {
return {
accessible: false,
message: "L'URL doit commencer par http:// ou https://"
};
}
// Tenter une requête HEAD pour vérifier l'accessibilité
const response = await fetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(5000), // Timeout de 5 secondes
});
return {
accessible: response.ok,
message: response.ok
? "L'URL est accessible"
: `L'URL a retourné une erreur HTTP ${response.status}`,
statusCode: response.status
};
} catch (error) {
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
return {
accessible: false,
message: "Format d'URL invalide"
};
}
return {
accessible: false,
message: `Erreur lors du test: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
};
}
}

View File

@@ -0,0 +1,149 @@
import { eq, and } from "drizzle-orm";
import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema";
import { getDb } from "./db";
/**
* Valider la présence d'un apprenant (par QR code ou manuellement)
*/
export async function validerPresence(params: {
inscriptionId: number;
dateFormationId: number;
modeValidation: "qrcode" | "manuel";
validateurId?: number;
commentaire?: string;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier si la présence existe déjà
const presenceExistante = await db
.select()
.from(presences)
.where(
and(
eq(presences.inscriptionId, params.inscriptionId),
eq(presences.dateFormationId, params.dateFormationId)
)
)
.limit(1);
if (presenceExistante.length > 0) {
throw new Error("Présence déjà validée pour cette date");
}
// Créer la présence
await db.insert(presences).values({
inscriptionId: params.inscriptionId,
dateFormationId: params.dateFormationId,
heurePresence: new Date(),
modeValidation: params.modeValidation,
validateurId: params.validateurId,
commentaire: params.commentaire,
});
return { success: true };
}
/**
* Récupérer toutes les présences pour une séquence
*/
export async function getPresencesBySequence(sequenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
presenceId: presences.id,
inscriptionId: presences.inscriptionId,
dateFormationId: presences.dateFormationId,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,
commentaire: presences.commentaire,
apprenantId: apprenants.id,
apprenantNom: apprenants.nom,
apprenantPrenom: apprenants.prenom,
apprenantEmail: apprenants.email,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
})
.from(presences)
.innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id))
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
.where(eq(datesFormation.sequenceId, sequenceId));
return result;
}
/**
* Récupérer les présences pour une inscription spécifique
*/
export async function getPresencesByInscription(inscriptionId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db
.select({
presenceId: presences.id,
dateFormationId: presences.dateFormationId,
heurePresence: presences.heurePresence,
modeValidation: presences.modeValidation,
validateurId: presences.validateurId,
commentaire: presences.commentaire,
dateDebut: datesFormation.dateDebut,
dateFin: datesFormation.dateFin,
})
.from(presences)
.innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id))
.where(eq(presences.inscriptionId, inscriptionId));
return result;
}
/**
* Vérifier si toutes les présences sont validées pour une inscription
*/
export async function checkAllPresencesValidated(inscriptionId: number): Promise<boolean> {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Récupérer l'inscription avec la séquence
const inscription = await db
.select({
sequenceId: inscriptions.sequenceId,
})
.from(inscriptions)
.where(eq(inscriptions.id, inscriptionId))
.limit(1);
if (inscription.length === 0) {
throw new Error("Inscription not found");
}
// Compter le nombre de dates de formation pour cette séquence
const datesCount = await db
.select({ count: datesFormation.id })
.from(datesFormation)
.where(eq(datesFormation.sequenceId, inscription[0].sequenceId));
// Compter le nombre de présences validées pour cette inscription
const presencesCount = await db
.select({ count: presences.id })
.from(presences)
.where(eq(presences.inscriptionId, inscriptionId));
return presencesCount.length === datesCount.length;
}
/**
* Supprimer une présence
*/
export async function supprimerPresence(presenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(presences).where(eq(presences.id, presenceId));
return { success: true };
}

View File

@@ -0,0 +1,63 @@
import QRCode from "qrcode";
import { randomBytes } from "crypto";
import { getParametres } from "./parametresDb";
/**
* Générer un token unique pour le QR code
*/
export function generateQRToken(): string {
return randomBytes(32).toString("hex");
}
/**
* Générer un QR code en base64 à partir d'un token
* @param token Le token unique de la séquence
* @returns Une image QR code en base64 (data URL)
*/
export async function generateQRCodeDataURL(token: string): Promise<string> {
// URL complète pour scanner le QR code
const parametres = await getParametres();
const url = `${parametres.urlPublique}/emargement/${token}`;
try {
const qrCodeDataURL = await QRCode.toDataURL(url, {
errorCorrectionLevel: "H",
type: "image/png",
width: 400,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
return qrCodeDataURL;
} catch (error) {
console.error("Erreur lors de la génération du QR code:", error);
throw new Error("Impossible de générer le QR code");
}
}
/**
* Générer un QR code en buffer PNG
* @param token Le token unique de la séquence
* @returns Un buffer PNG du QR code
*/
export async function generateQRCodeBuffer(token: string): Promise<Buffer> {
const parametres = await getParametres();
const url = `${parametres.urlPublique}/emargement/${token}`;
try {
const buffer = await QRCode.toBuffer(url, {
errorCorrectionLevel: "H",
type: "png",
width: 400,
margin: 2,
});
return buffer;
} catch (error) {
console.error("Erreur lors de la génération du QR code:", error);
throw new Error("Impossible de générer le QR code");
}
}

View File

@@ -0,0 +1,250 @@
import { eq, and, desc, sql } from "drizzle-orm";
import { getDb } from "./db";
import {
questionnaires,
questions,
reponsesQuestionnaires,
reponsesQuestions,
envoisQuestionnaires,
type Questionnaire,
type InsertQuestionnaire,
type Question,
type InsertQuestion,
type ReponseQuestionnaire,
type InsertReponseQuestionnaire,
type ReponseQuestion,
type InsertReponseQuestion,
type EnvoiQuestionnaire,
type InsertEnvoiQuestionnaire,
} from "../drizzle/schema";
// ========== QUESTIONNAIRES ==========
export async function getAllQuestionnaires() {
const db = await getDb();
if (!db) return [];
return await db.select().from(questionnaires).orderBy(desc(questionnaires.createdAt));
}
export async function getQuestionnaireById(id: number) {
const db = await getDb();
if (!db) return null;
const result = await db.select().from(questionnaires).where(eq(questionnaires.id, id)).limit(1);
return result[0] || null;
}
export async function createQuestionnaire(data: InsertQuestionnaire) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(questionnaires).values(data);
return result[0].insertId;
}
export async function updateQuestionnaire(id: number, data: Partial<InsertQuestionnaire>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(questionnaires).set(data).where(eq(questionnaires.id, id));
}
export async function deleteQuestionnaire(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer d'abord les questions associées
await db.delete(questions).where(eq(questions.questionnaireId, id));
// Puis le questionnaire
await db.delete(questionnaires).where(eq(questionnaires.id, id));
}
// ========== QUESTIONS ==========
export async function getQuestionsByQuestionnaireId(questionnaireId: number) {
const db = await getDb();
if (!db) return [];
return await db
.select()
.from(questions)
.where(eq(questions.questionnaireId, questionnaireId))
.orderBy(questions.ordre);
}
export async function createQuestion(data: InsertQuestion) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(questions).values(data);
return result[0].insertId;
}
export async function updateQuestion(id: number, data: Partial<InsertQuestion>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(questions).set(data).where(eq(questions.id, id));
}
export async function deleteQuestion(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(questions).where(eq(questions.id, id));
}
// ========== RÉPONSES QUESTIONNAIRES ==========
export async function createReponseQuestionnaire(data: InsertReponseQuestionnaire) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(reponsesQuestionnaires).values(data);
return result[0].insertId;
}
export async function getReponseQuestionnaire(questionnaireId: number, apprenantId: number, sequenceId: number) {
const db = await getDb();
if (!db) return null;
const result = await db
.select()
.from(reponsesQuestionnaires)
.where(
and(
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
eq(reponsesQuestionnaires.apprenantId, apprenantId),
eq(reponsesQuestionnaires.sequenceId, sequenceId)
)
)
.limit(1);
return result[0] || null;
}
export async function updateReponseQuestionnaire(id: number, data: Partial<InsertReponseQuestionnaire>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(reponsesQuestionnaires).set(data).where(eq(reponsesQuestionnaires.id, id));
}
// ========== RÉPONSES QUESTIONS ==========
export async function createReponseQuestion(data: InsertReponseQuestion) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(reponsesQuestions).values(data);
return result[0].insertId;
}
export async function getReponsesByReponseQuestionnaireId(reponseQuestionnaireId: number) {
const db = await getDb();
if (!db) return [];
return await db
.select()
.from(reponsesQuestions)
.where(eq(reponsesQuestions.reponseQuestionnaireId, reponseQuestionnaireId));
}
// ========== ENVOIS QUESTIONNAIRES ==========
export async function createEnvoiQuestionnaire(data: InsertEnvoiQuestionnaire) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(envoisQuestionnaires).values(data);
return result[0].insertId;
}
export async function getEnvoiByToken(token: string) {
const db = await getDb();
if (!db) return null;
const result = await db
.select()
.from(envoisQuestionnaires)
.where(eq(envoisQuestionnaires.token, token))
.limit(1);
return result[0] || null;
}
export async function updateEnvoiQuestionnaire(id: number, data: Partial<InsertEnvoiQuestionnaire>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(envoisQuestionnaires).set(data).where(eq(envoisQuestionnaires.id, id));
}
export async function checkEnvoiExists(questionnaireId: number, apprenantId: number, sequenceId: number) {
const db = await getDb();
if (!db) return false;
const result = await db
.select()
.from(envoisQuestionnaires)
.where(
and(
eq(envoisQuestionnaires.questionnaireId, questionnaireId),
eq(envoisQuestionnaires.apprenantId, apprenantId),
eq(envoisQuestionnaires.sequenceId, sequenceId)
)
)
.limit(1);
return result.length > 0;
}
// ========== STATISTIQUES ==========
/**
* Récupère les statistiques d'un questionnaire
*/
export async function getQuestionnaireStats(questionnaireId: number) {
const db = await getDb();
if (!db) return null;
// Nombre total d'envois
const envoisResult = await db
.select({ count: sql<number>`count(*)` })
.from(envoisQuestionnaires)
.where(eq(envoisQuestionnaires.questionnaireId, questionnaireId));
const totalEnvois = envoisResult[0]?.count || 0;
// Nombre de réponses
const reponsesResult = await db
.select({ count: sql<number>`count(*)` })
.from(reponsesQuestionnaires)
.where(
and(
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
eq(reponsesQuestionnaires.complete, true)
)
);
const totalReponses = reponsesResult[0]?.count || 0;
// Taux de réponse
const tauxReponse = totalEnvois > 0 ? (totalReponses / totalEnvois) * 100 : 0;
return {
totalEnvois,
totalReponses,
tauxReponse: Math.round(tauxReponse * 10) / 10, // 1 décimale
};
}
/**
* Récupère les réponses détaillées d'un questionnaire pour analyse
*/
export async function getReponsesDetailleesQuestionnaire(questionnaireId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
reponseQuestionnaireId: reponsesQuestionnaires.id,
apprenantId: reponsesQuestionnaires.apprenantId,
sequenceId: reponsesQuestionnaires.sequenceId,
dateReponse: reponsesQuestionnaires.dateReponse,
questionId: reponsesQuestions.questionId,
reponseNumerique: reponsesQuestions.reponseNumerique,
reponseTexte: reponsesQuestions.reponseTexte,
})
.from(reponsesQuestionnaires)
.leftJoin(
reponsesQuestions,
eq(reponsesQuestions.reponseQuestionnaireId, reponsesQuestionnaires.id)
)
.where(
and(
eq(reponsesQuestionnaires.questionnaireId, questionnaireId),
eq(reponsesQuestionnaires.complete, true)
)
);
return result;
}

View File

@@ -0,0 +1,174 @@
import ExcelJS from 'exceljs';
import { jsPDF } from 'jspdf';
import { getQuestionnaireById, getQuestionsByQuestionnaireId, getQuestionnaireStats, getReponsesDetailleesQuestionnaire } from './questionnaireDb';
import { getDb } from './db';
import { sql } from 'drizzle-orm';
/**
* Génère un fichier Excel avec les statistiques du questionnaire
*/
export async function exportQuestionnaireToExcel(questionnaireId: number): Promise<Buffer> {
const questionnaire = await getQuestionnaireById(questionnaireId);
const stats = await getQuestionnaireStats(questionnaireId);
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
const reponsesData = await getReponsesDetailleesQuestionnaire(questionnaireId);
if (!questionnaire || !stats) {
throw new Error('Questionnaire introuvable');
}
const workbook = new ExcelJS.Workbook();
// Feuille 1: Statistiques globales
const statsSheet = workbook.addWorksheet('Statistiques');
statsSheet.addRow(['Questionnaire', questionnaire.titre]);
statsSheet.addRow(['Type', questionnaire.type]);
statsSheet.addRow(['']);
statsSheet.addRow(['Nombre d\'envois', stats.totalEnvois]);
statsSheet.addRow(['Nombre de réponses', stats.totalReponses]);
statsSheet.addRow(['Taux de réponse', `${stats.tauxReponse.toFixed(1)}%`]);
statsSheet.addRow(['']);
// Style pour l'en-tête
statsSheet.getColumn(1).width = 30;
statsSheet.getColumn(2).width = 40;
statsSheet.getRow(1).font = { bold: true, size: 14 };
// Feuille 2: Statistiques par question
const questionsSheet = workbook.addWorksheet('Par question');
questionsSheet.addRow(['Question', 'Type', 'Ordre']);
questionsSheet.getRow(1).font = { bold: true };
questionsSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
};
questionsData.forEach((q: any) => {
questionsSheet.addRow([
q.texte,
q.typeQuestion,
q.ordre
]);
});
questionsSheet.getColumn(1).width = 60;
questionsSheet.getColumn(2).width = 20;
questionsSheet.getColumn(3).width = 10;
// Feuille 3: Réponses brutes
const reponsesSheet = workbook.addWorksheet('Réponses brutes');
reponsesSheet.addRow(['Date réponse', 'Apprenant ID', 'Séquence ID', 'Question ID', 'Réponse numérique', 'Réponse texte']);
reponsesSheet.getRow(1).font = { bold: true };
reponsesSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' }
};
reponsesData.forEach((r: any) => {
reponsesSheet.addRow([
r.dateReponse ? new Date(r.dateReponse).toLocaleDateString('fr-FR') : '',
r.apprenantId,
r.sequenceId,
r.questionId,
r.reponseNumerique || '',
r.reponseTexte || ''
]);
});
reponsesSheet.getColumn(1).width = 15;
reponsesSheet.getColumn(2).width = 15;
reponsesSheet.getColumn(3).width = 15;
reponsesSheet.getColumn(4).width = 15;
reponsesSheet.getColumn(5).width = 20;
reponsesSheet.getColumn(6).width = 50;
// Générer le buffer
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
/**
* Génère un fichier PDF avec les statistiques du questionnaire
*/
export async function exportQuestionnaireToPDF(questionnaireId: number): Promise<Buffer> {
const questionnaire = await getQuestionnaireById(questionnaireId);
const stats = await getQuestionnaireStats(questionnaireId);
const questionsData = await getQuestionsByQuestionnaireId(questionnaireId);
if (!questionnaire || !stats) {
throw new Error('Questionnaire introuvable');
}
const doc = new jsPDF();
let yPos = 20;
// Titre
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(questionnaire.titre, 20, yPos);
yPos += 10;
// Type
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.text(`Type: ${questionnaire.type}`, 20, yPos);
yPos += 15;
// Statistiques globales
doc.setFont('helvetica', 'bold');
doc.text('Statistiques globales', 20, yPos);
yPos += 8;
doc.setFont('helvetica', 'normal');
doc.text(`Nombre d'envois: ${stats.totalEnvois}`, 20, yPos);
yPos += 6;
doc.text(`Nombre de réponses: ${stats.totalReponses}`, 20, yPos);
yPos += 6;
doc.text(`Taux de réponse: ${stats.tauxReponse.toFixed(1)}%`, 20, yPos);
yPos += 15;
// Liste des questions
doc.setFont('helvetica', 'bold');
doc.text('Questions du questionnaire', 20, yPos);
yPos += 10;
questionsData.forEach((q: any, index: number) => {
// Vérifier si on doit ajouter une nouvelle page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
const questionText = `${index + 1}. ${q.texte}`;
const lines = doc.splitTextToSize(questionText, 170);
doc.text(lines, 20, yPos);
yPos += lines.length * 6;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Type: ${q.typeQuestion}`, 25, yPos);
yPos += 8;
});
// Pied de page
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${i} sur ${pageCount} - Généré le ${new Date().toLocaleDateString('fr-FR')}`,
20,
290
);
}
return Buffer.from(doc.output('arraybuffer'));
}

View File

@@ -0,0 +1,248 @@
import { getDb } from "./db";
import { eq, and, lte, sql } from "drizzle-orm";
import {
questionnaires,
sequences,
inscriptions,
apprenants,
datesFormation,
envoisQuestionnaires,
} from "../drizzle/schema";
import {
getAllQuestionnaires,
checkEnvoiExists,
createEnvoiQuestionnaire,
} from "./questionnaireDb";
import { sendEmail } from "./_core/emailSender";
import crypto from "crypto";
/**
* Génère un token unique pour accéder au questionnaire
*/
function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Envoie un questionnaire à un apprenant pour une séquence donnée
*/
async function envoyerQuestionnaire(
questionnaireId: number,
apprenantId: number,
sequenceId: number,
apprenantEmail: string,
apprenantNom: string,
apprenantPrenom: string,
questionnairenom: string,
sequenceNom: string
) {
// Vérifier si le questionnaire n'a pas déjà été envoyé
const dejaEnvoye = await checkEnvoiExists(questionnaireId, apprenantId, sequenceId);
if (dejaEnvoye) {
console.log(
`[Questionnaires] Questionnaire ${questionnaireId} déjà envoyé à l'apprenant ${apprenantId} pour la séquence ${sequenceId}`
);
return false;
}
// Générer un token unique
const token = generateToken();
// Créer l'envoi
await createEnvoiQuestionnaire({
questionnaireId,
apprenantId,
sequenceId,
token,
dateEnvoi: new Date(),
dateReponse: null,
});
// Envoyer l'email
const lienQuestionnaire = `${process.env.VITE_FRONTEND_URL || "http://localhost:3000"}/questionnaire/${token}`;
const emailSent = await sendEmail({
to: apprenantEmail,
subject: `Questionnaire : ${questionnairenom}`,
html: `
<h2>Bonjour ${apprenantPrenom} ${apprenantNom},</h2>
<p>Nous vous remercions d'avoir participé à la formation <strong>${sequenceNom}</strong>.</p>
<p>Afin d'améliorer continuellement la qualité de nos formations, nous vous invitons à répondre à ce questionnaire :</p>
<p style="margin: 30px 0;">
<a href="${lienQuestionnaire}"
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Répondre au questionnaire
</a>
</p>
<p>Ce questionnaire ne vous prendra que quelques minutes.</p>
<p>Merci pour votre participation !</p>
<p style="margin-top: 30px; font-size: 12px; color: #666;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<a href="${lienQuestionnaire}">${lienQuestionnaire}</a>
</p>
`,
});
if (emailSent) {
console.log(
`[Questionnaires] Questionnaire ${questionnaireId} envoyé à ${apprenantEmail} pour la séquence ${sequenceId}`
);
return true;
} else {
console.error(
`[Questionnaires] Échec de l'envoi du questionnaire ${questionnaireId} à ${apprenantEmail}`
);
return false;
}
}
/**
* Traite les envois automatiques de questionnaires post-formation
*/
export async function processEnvoisAutomatiques() {
console.log("[Questionnaires] Démarrage du traitement des envois automatiques...");
const db = await getDb();
if (!db) {
console.log("[Questionnaires] Base de données non disponible");
return { success: false, message: "Base de données non disponible" };
}
try {
// Récupérer tous les questionnaires actifs avec envoi automatique
const questionnairesActifs = await getAllQuestionnaires();
const questionnairesAEnvoyer = questionnairesActifs.filter(
(q) => q.actif && q.envoiAutomatique
);
if (questionnairesAEnvoyer.length === 0) {
console.log("[Questionnaires] Aucun questionnaire avec envoi automatique configuré");
return { success: true, message: "Aucun questionnaire à envoyer", count: 0 };
}
let totalEnvoyes = 0;
for (const questionnaire of questionnairesAEnvoyer) {
console.log(
`[Questionnaires] Traitement du questionnaire "${questionnaire.titre}" (délai: J+${questionnaire.delaiEnvoiJours})`
);
// Calculer la date cible (aujourd'hui - délai)
const dateCible = new Date();
dateCible.setDate(dateCible.getDate() - questionnaire.delaiEnvoiJours);
dateCible.setHours(0, 0, 0, 0);
// Récupérer les séquences terminées à la date cible
const sequencesTerminees = await db
.select({
sequenceId: sequences.id,
sequenceNom: sequences.nom,
formationId: sequences.formationId,
})
.from(sequences)
.innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id))
.where(lte(datesFormation.dateFin, dateCible))
.groupBy(sequences.id);
console.log(
`[Questionnaires] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s) pour le ${dateCible.toLocaleDateString("fr-FR")}`
);
// Pour chaque séquence terminée
for (const seq of sequencesTerminees) {
// Vérifier si le questionnaire est lié à une formation spécifique
if (questionnaire.formationId && questionnaire.formationId !== seq.formationId) {
continue;
}
// Récupérer les apprenants inscrits et validés
const apprenantsInscrits = await db
.select({
apprenantId: apprenants.id,
apprenantEmail: apprenants.email,
apprenantNom: apprenants.nom,
apprenantPrenom: apprenants.prenom,
})
.from(inscriptions)
.innerJoin(apprenants, eq(apprenants.id, inscriptions.apprenantId))
.where(
and(
eq(inscriptions.sequenceId, seq.sequenceId),
eq(inscriptions.statut, "confirmee")
)
);
console.log(
`[Questionnaires] ${apprenantsInscrits.length} apprenant(s) inscrit(s) à la séquence ${seq.sequenceId}`
);
// Envoyer le questionnaire à chaque apprenant
for (const apprenant of apprenantsInscrits) {
if (!apprenant.apprenantEmail) {
console.log(
`[Questionnaires] Apprenant ${apprenant.apprenantId} sans email, envoi ignoré`
);
continue;
}
const envoye = await envoyerQuestionnaire(
questionnaire.id,
apprenant.apprenantId,
seq.sequenceId,
apprenant.apprenantEmail,
apprenant.apprenantNom,
apprenant.apprenantPrenom,
questionnaire.titre,
seq.sequenceNom
);
if (envoye) {
totalEnvoyes++;
}
}
}
}
console.log(`[Questionnaires] Traitement terminé - ${totalEnvoyes} questionnaire(s) envoyé(s)`);
return {
success: true,
message: `${totalEnvoyes} questionnaire(s) envoyé(s)`,
count: totalEnvoyes,
};
} catch (error: any) {
console.error("[Questionnaires] Erreur lors du traitement :", error);
return { success: false, message: error.message, count: 0 };
}
}
/**
* Initialise le scheduler pour les envois automatiques de questionnaires
* Exécute le traitement tous les jours à 9h00
*/
export function initQuestionnaireScheduler() {
console.log("[Questionnaires] Initialisation du scheduler d'envoi automatique");
// Exécuter immédiatement au démarrage
processEnvoisAutomatiques();
// Puis exécuter tous les jours à 9h00
const interval = setInterval(
() => {
const now = new Date();
if (now.getHours() === 9 && now.getMinutes() === 0) {
processEnvoisAutomatiques();
}
},
60 * 1000 // Vérifier toutes les minutes
);
console.log("[Questionnaires] Scheduler initialisé - vérification quotidienne à 9h00");
return interval;
}

View File

@@ -0,0 +1,164 @@
import { eq, sql, and, gte, lte } from "drizzle-orm";
import { getDb } from "./db";
import {
questionnaires,
envoisQuestionnaires,
reponsesQuestionnaires,
reponsesQuestions,
questions,
sequences,
formations,
formateurs
} from "../drizzle/schema";
/**
* Statistiques de taux de réponse par formation
*/
export async function getStatsByFormation() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const results = await db
.select({
formationId: formations.id,
formationNom: formations.nom,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(envoisQuestionnaires)
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
.innerJoin(formations, eq(sequences.formationId, formations.id))
.groupBy(formations.id, formations.nom);
return results.map(r => ({
formationId: r.formationId,
formationNom: r.formationNom,
totalEnvois: Number(r.totalEnvois),
totalReponses: Number(r.totalReponses),
tauxReponse: Number(r.totalEnvois) > 0
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
: 0
}));
}
/**
* Évolution temporelle des réponses (par mois)
*/
export async function getEvolutionTemporelle(startDate?: Date, endDate?: Date) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const conditions = [];
if (startDate) {
conditions.push(gte(envoisQuestionnaires.dateEnvoi, startDate));
}
if (endDate) {
conditions.push(lte(envoisQuestionnaires.dateEnvoi, endDate));
}
const results = await db
.select({
mois: sql<string>`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(envoisQuestionnaires)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.groupBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`)
.orderBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`);
return results.map(r => ({
mois: r.mois,
totalEnvois: Number(r.totalEnvois),
totalReponses: Number(r.totalReponses),
tauxReponse: Number(r.totalEnvois) > 0
? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100)
: 0
}));
}
/**
* Comparaison par formateur
*/
export async function getStatsByFormateur() {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Première requête : statistiques d'envois et réponses par formateur
const statsEnvois = await db
.select({
formateurId: formateurs.id,
formateurNom: formateurs.nom,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(envoisQuestionnaires)
.innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id))
.innerJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.groupBy(formateurs.id, formateurs.nom);
// Pour chaque formateur, calculer la moyenne de satisfaction
const results = await Promise.all(statsEnvois.map(async (stat) => {
// Calculer la moyenne des réponses de type échelle pour ce formateur
const satisfactionResult = await db
.select({
moyenne: sql<number>`AVG(CAST(${reponsesQuestions.reponseNumerique} AS DECIMAL(10,2)))`,
})
.from(reponsesQuestionnaires)
.innerJoin(sequences, eq(reponsesQuestionnaires.sequenceId, sequences.id))
.innerJoin(reponsesQuestions, eq(reponsesQuestionnaires.id, reponsesQuestions.reponseQuestionnaireId))
.innerJoin(questions, eq(reponsesQuestions.questionId, questions.id))
.where(
and(
eq(sequences.formateurId, stat.formateurId),
eq(reponsesQuestionnaires.complete, true),
eq(questions.typeQuestion, 'echelle')
)
);
const moyenneSatisfaction = satisfactionResult[0]?.moyenne
? Number(satisfactionResult[0].moyenne).toFixed(1)
: null;
return {
formateurId: stat.formateurId,
formateurNom: stat.formateurNom || "Non spécifié",
totalEnvois: Number(stat.totalEnvois),
totalReponses: Number(stat.totalReponses),
tauxReponse: Number(stat.totalEnvois) > 0
? Math.round((Number(stat.totalReponses) / Number(stat.totalEnvois)) * 100)
: 0,
moyenneSatisfaction
};
}));
return results;
}
/**
* Statistiques globales pour le tableau de bord
*/
export async function getStatsGlobales() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const results = await db
.select({
totalQuestionnaires: sql<number>`COUNT(DISTINCT ${questionnaires.id})`,
totalEnvois: sql<number>`COUNT(DISTINCT ${envoisQuestionnaires.id})`,
totalReponses: sql<number>`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`,
})
.from(questionnaires)
.leftJoin(envoisQuestionnaires, eq(questionnaires.id, envoisQuestionnaires.questionnaireId));
const stats = results[0];
const totalEnvois = Number(stats.totalEnvois) || 0;
const totalReponses = Number(stats.totalReponses) || 0;
return {
totalQuestionnaires: Number(stats.totalQuestionnaires) || 0,
totalEnvois,
totalReponses,
tauxReponseGlobal: totalEnvois > 0 ? Number(((totalReponses / totalEnvois) * 100).toFixed(1)) : 0
};
}

View File

@@ -0,0 +1,328 @@
import { getDb } from "./db";
import { logsRappels, InsertLogRappel, LogRappel } from "../drizzle/schema";
import { eq, and } from "drizzle-orm";
/**
* Enregistre l'envoi d'un rappel dans les logs
*/
export async function logRappelEnvoye(data: {
rappelId: number;
sequenceId: number;
apprenantId: number;
emailDestinataire: string;
typeRappel: string;
statut: "success" | "failed";
messageErreur?: string;
dateSequence: Date;
}): Promise<void> {
const db = await getDb();
if (!db) {
console.warn("[LogsRappels] Base de données non disponible");
return;
}
try {
await db.insert(logsRappels).values({
rappelId: data.rappelId,
sequenceId: data.sequenceId,
apprenantId: data.apprenantId,
emailDestinataire: data.emailDestinataire,
typeRappel: data.typeRappel,
dateEnvoi: new Date(),
statut: data.statut,
messageErreur: data.messageErreur || null,
dateSequence: data.dateSequence,
} as InsertLogRappel);
} catch (error) {
console.error("[LogsRappels] Erreur lors de l'enregistrement du log:", error);
}
}
/**
* Vérifie si un rappel a déjà été envoyé à un apprenant pour une séquence donnée
*/
export async function rappelDejaEnvoye(
rappelId: number,
sequenceId: number,
apprenantId: number
): Promise<boolean> {
const db = await getDb();
if (!db) {
console.warn("[LogsRappels] Base de données non disponible");
return false;
}
try {
const logs = await db
.select()
.from(logsRappels)
.where(
and(
eq(logsRappels.rappelId, rappelId),
eq(logsRappels.sequenceId, sequenceId),
eq(logsRappels.apprenantId, apprenantId),
eq(logsRappels.statut, "success")
)
)
.limit(1);
return logs.length > 0;
} catch (error) {
console.error("[LogsRappels] Erreur lors de la vérification des doublons:", error);
return false;
}
}
/**
* Récupère tous les logs de rappels pour une séquence donnée
*/
export async function getLogsRappelsBySequence(sequenceId: number): Promise<LogRappel[]> {
const db = await getDb();
if (!db) return [];
try {
return await db
.select()
.from(logsRappels)
.where(eq(logsRappels.sequenceId, sequenceId))
.orderBy(logsRappels.dateEnvoi);
} catch (error) {
console.error("[LogsRappels] Erreur lors de la récupération des logs:", error);
return [];
}
}
/**
* Récupère tous les logs de rappels échoués récents (dernières 24h)
*/
export async function getLogsRappelsEchoues(): Promise<LogRappel[]> {
const db = await getDb();
if (!db) return [];
try {
const hier = new Date();
hier.setDate(hier.getDate() - 1);
return await db
.select()
.from(logsRappels)
.where(
and(
eq(logsRappels.statut, "failed"),
// Note: Drizzle ne supporte pas directement les comparaisons de dates
// On récupère tous les échecs et on filtre en JS
)
)
.orderBy(logsRappels.dateEnvoi);
} catch (error) {
console.error("[LogsRappels] Erreur lors de la récupération des échecs:", error);
return [];
}
}
/**
* Compte le nombre de rappels envoyés avec succès pour une séquence
*/
export async function countRappelsEnvoyesSequence(sequenceId: number): Promise<number> {
const db = await getDb();
if (!db) return 0;
try {
const logs = await db
.select()
.from(logsRappels)
.where(
and(
eq(logsRappels.sequenceId, sequenceId),
eq(logsRappels.statut, "success")
)
);
return logs.length;
} catch (error) {
console.error("[LogsRappels] Erreur lors du comptage:", error);
return 0;
}
}
/**
* Récupère les logs de rappels avec filtres
*/
export async function getLogsRappelsWithFilters(filters: {
dateDebut?: Date;
dateFin?: Date;
sequenceId?: number;
statut?: "success" | "failed";
email?: string;
limit?: number;
offset?: number;
}): Promise<LogRappel[]> {
const db = await getDb();
if (!db) return [];
try {
let query = db.select().from(logsRappels);
const conditions = [];
if (filters.sequenceId) {
conditions.push(eq(logsRappels.sequenceId, filters.sequenceId));
}
if (filters.statut) {
conditions.push(eq(logsRappels.statut, filters.statut));
}
if (filters.email) {
// Note: Drizzle ne supporte pas LIKE directement, on filtre en JS
}
if (conditions.length > 0) {
query = query.where(and(...conditions)) as any;
}
let logs = await query.orderBy(logsRappels.dateEnvoi).limit(filters.limit || 100).offset(filters.offset || 0);
// Filtrer par email si nécessaire
if (filters.email) {
logs = logs.filter(log => log.emailDestinataire.toLowerCase().includes(filters.email!.toLowerCase()));
}
// Filtrer par date si nécessaire
if (filters.dateDebut) {
logs = logs.filter(log => new Date(log.dateEnvoi) >= filters.dateDebut!);
}
if (filters.dateFin) {
logs = logs.filter(log => new Date(log.dateEnvoi) <= filters.dateFin!);
}
return logs;
} catch (error) {
console.error("[LogsRappels] Erreur lors de la récupération avec filtres:", error);
return [];
}
}
/**
* Calcule les statistiques des rappels
*/
export async function getStatsRappels(): Promise<{
totalEnvoyes: number;
totalSucces: number;
totalEchecs: number;
tauxSucces: number;
emailsProblematiques: Array<{ email: string; nbEchecs: number }>;
}> {
const db = await getDb();
if (!db) {
return {
totalEnvoyes: 0,
totalSucces: 0,
totalEchecs: 0,
tauxSucces: 0,
emailsProblematiques: [],
};
}
try {
const logs = await db.select().from(logsRappels);
const totalEnvoyes = logs.length;
const totalSucces = logs.filter(l => l.statut === "success").length;
const totalEchecs = logs.filter(l => l.statut === "failed").length;
const tauxSucces = totalEnvoyes > 0 ? (totalSucces / totalEnvoyes) * 100 : 0;
// Compter les échecs par email
const echecsParEmail = new Map<string, number>();
logs.filter(l => l.statut === "failed").forEach(log => {
const count = echecsParEmail.get(log.emailDestinataire) || 0;
echecsParEmail.set(log.emailDestinataire, count + 1);
});
const emailsProblematiques = Array.from(echecsParEmail.entries())
.map(([email, nbEchecs]) => ({ email, nbEchecs }))
.sort((a, b) => b.nbEchecs - a.nbEchecs)
.slice(0, 10); // Top 10
return {
totalEnvoyes,
totalSucces,
totalEchecs,
tauxSucces,
emailsProblematiques,
};
} catch (error) {
console.error("[LogsRappels] Erreur lors du calcul des statistiques:", error);
return {
totalEnvoyes: 0,
totalSucces: 0,
totalEchecs: 0,
tauxSucces: 0,
emailsProblematiques: [],
};
}
}
/**
* Récupère l'évolution des envois par jour
*/
export async function getEvolutionEnvois(): Promise<Array<{
date: string;
nbEnvoyes: number;
nbSucces: number;
nbEchecs: number;
}>> {
const db = await getDb();
if (!db) return [];
try {
const logs = await db.select().from(logsRappels).orderBy(logsRappels.dateEnvoi);
// Grouper par jour
const parJour = new Map<string, { nbEnvoyes: number; nbSucces: number; nbEchecs: number }>();
logs.forEach(log => {
const dateStr = new Date(log.dateEnvoi).toISOString().split('T')[0];
const stats = parJour.get(dateStr) || { nbEnvoyes: 0, nbSucces: 0, nbEchecs: 0 };
stats.nbEnvoyes++;
if (log.statut === "success") stats.nbSucces++;
else stats.nbEchecs++;
parJour.set(dateStr, stats);
});
return Array.from(parJour.entries())
.map(([date, stats]) => ({ date, ...stats }))
.sort((a, b) => a.date.localeCompare(b.date));
} catch (error) {
console.error("[LogsRappels] Erreur lors de la récupération de l'évolution:", error);
return [];
}
}
/**
* Récupère les logs échoués à réessayer
*/
export async function getLogsAReessayer(): Promise<LogRappel[]> {
const db = await getDb();
if (!db) return [];
try {
const maintenant = new Date();
const logs = await db
.select()
.from(logsRappels)
.where(
and(
eq(logsRappels.statut, "failed"),
// Limiter à 3 tentatives maximum
)
);
// Filtrer en JS pour les conditions complexes
return logs.filter(log => {
if (log.nbTentatives >= 3) return false;
if (!log.prochainEssai) return true; // Premier essai
return new Date(log.prochainEssai) <= maintenant;
});
} catch (error) {
console.error("[LogsRappels] Erreur lors de la récupération des logs à réessayer:", error);
return [];
}
}

View File

@@ -0,0 +1,226 @@
import { getDb } from "./db";
import { logsRappels, sequences, inscriptions, apprenants, formations, datesFormation, rappels, formateurs } from "../drizzle/schema";
import { eq, and } from "drizzle-orm";
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
import { getLogsAReessayer } from "./rappelDb";
import { notifyOwner } from "./_core/notification";
/**
* Calcule le délai avant le prochain essai selon le nombre de tentatives
* Délai exponentiel : 1h, 4h, 24h
*/
function calculerProchainEssai(nbTentatives: number): Date {
const maintenant = new Date();
let delaiHeures = 1; // 1 heure par défaut
if (nbTentatives === 1) {
delaiHeures = 1; // 1ère tentative échouée → réessayer dans 1h
} else if (nbTentatives === 2) {
delaiHeures = 4; // 2ème tentative échouée → réessayer dans 4h
} else {
delaiHeures = 24; // 3ème tentative échouée → réessayer dans 24h (mais on arrête à 3)
}
maintenant.setHours(maintenant.getHours() + delaiHeures);
return maintenant;
}
/**
* Réessaye d'envoyer les rappels échoués
*/
export async function processRappelRetry() {
console.log("[Rappels Retry] Démarrage du processus de réessai");
const db = await getDb();
if (!db) {
console.error("[Rappels Retry] Base de données non disponible");
return;
}
try {
// Récupérer les logs à réessayer
const logsAReessayer = await getLogsAReessayer();
if (logsAReessayer.length === 0) {
console.log("[Rappels Retry] Aucun rappel à réessayer");
return;
}
console.log(`[Rappels Retry] ${logsAReessayer.length} rappel(s) à réessayer`);
let nbSucces = 0;
let nbEchecs = 0;
for (const log of logsAReessayer) {
try {
// Récupérer les informations de la séquence
const sequence = await db
.select()
.from(sequences)
.where(eq(sequences.id, log.sequenceId))
.limit(1);
if (sequence.length === 0) {
console.warn(`[Rappels Retry] Séquence ${log.sequenceId} introuvable`);
continue;
}
const seq = sequence[0];
// Récupérer la formation
const formation = await db
.select()
.from(formations)
.where(eq(formations.id, seq.formationId))
.limit(1);
if (formation.length === 0) {
console.warn(`[Rappels Retry] Formation ${seq.formationId} introuvable`);
continue;
}
// Récupérer l'apprenant
const apprenant = await db
.select()
.from(apprenants)
.where(eq(apprenants.id, log.apprenantId))
.limit(1);
if (apprenant.length === 0 || !apprenant[0].email) {
console.warn(`[Rappels Retry] Apprenant ${log.apprenantId} introuvable ou sans email`);
continue;
}
// Récupérer les dates de la séquence
const dates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, seq.id))
.orderBy(datesFormation.ordre);
// Récupérer le formateur si disponible
let formateurNom = "";
if (seq.formateurId) {
const formateur = await db
.select()
.from(formateurs)
.where(eq(formateurs.id, seq.formateurId))
.limit(1);
if (formateur.length > 0) {
formateurNom = formateur[0].nom;
}
}
// Réessayer l'envoi
try {
if (log.typeRappel === "rappel") {
await sendRappelJ7Email({
apprenantEmail: apprenant[0].email,
apprenantPrenom: apprenant[0].prenom,
apprenantNom: apprenant[0].nom,
apprenantFonction: apprenant[0].fonction,
formationNom: formation[0].nom,
sequenceNom: seq.nom,
dates: dates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: seq.lieu || "",
});
} else if (log.typeRappel === "rappelJ1") {
await sendRappelJ1Email({
apprenantEmail: apprenant[0].email,
apprenantPrenom: apprenant[0].prenom,
apprenantNom: apprenant[0].nom,
apprenantFonction: apprenant[0].fonction,
formationNom: formation[0].nom,
sequenceNom: seq.nom,
dates: dates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: seq.lieu || "",
});
}
// Succès : mettre à jour le log
await db
.update(logsRappels)
.set({
statut: "success",
nbTentatives: log.nbTentatives + 1,
prochainEssai: null,
})
.where(eq(logsRappels.id, log.id));
console.log(`[Rappels Retry] Succès pour ${apprenant[0].email} après ${log.nbTentatives + 1} tentative(s)`);
nbSucces++;
} catch (emailError: any) {
const messageErreur = emailError?.message || String(emailError);
const nouvelleTentative = log.nbTentatives + 1;
if (nouvelleTentative >= 3) {
// Abandon après 3 tentatives
await db
.update(logsRappels)
.set({
statut: "failed",
nbTentatives: nouvelleTentative,
messageErreur: `Abandon après 3 tentatives: ${messageErreur}`,
prochainEssai: null,
})
.where(eq(logsRappels.id, log.id));
console.error(`[Rappels Retry] Abandon pour ${apprenant[0].email} après 3 tentatives`);
// Notifier l'admin
await notifyOwner({
title: `❌ Abandon d'envoi de rappel`,
content: `Le rappel pour ${apprenant[0].email} (${seq.nom}) a échoué 3 fois et a été abandonné.\\n\\nDernière erreur: ${messageErreur}`,
});
} else {
// Planifier un nouvel essai
const prochainEssai = calculerProchainEssai(nouvelleTentative);
await db
.update(logsRappels)
.set({
nbTentatives: nouvelleTentative,
messageErreur: messageErreur,
prochainEssai: prochainEssai,
})
.where(eq(logsRappels.id, log.id));
console.log(`[Rappels Retry] Échec pour ${apprenant[0].email}, tentative ${nouvelleTentative}/3. Prochain essai: ${prochainEssai.toLocaleString()}`);
}
nbEchecs++;
}
} catch (error) {
console.error(`[Rappels Retry] Erreur lors du traitement du log ${log.id}:`, error);
}
}
console.log(`[Rappels Retry] Traitement terminé: ${nbSucces} succès, ${nbEchecs} échecs`);
} catch (error) {
console.error("[Rappels Retry] Erreur lors du processus de retry:", error);
}
}
/**
* Initialise le scheduler de retry
* Vérifie toutes les heures s'il y a des rappels à réessayer
*/
export function initRappelRetryScheduler() {
console.log("[Rappels Retry] Initialisation du scheduler de réessai automatique");
// Exécuter immédiatement au démarrage
processRappelRetry();
// Puis exécuter toutes les heures
setInterval(() => {
processRappelRetry();
}, 60 * 60 * 1000); // 1 heure en millisecondes
console.log("[Rappels Retry] Scheduler de réessai initialisé - vérification toutes les heures");
}

View File

@@ -0,0 +1,294 @@
import { getDb } from "./db";
import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema";
import { eq, and, gte, lte, sql, inArray } from "drizzle-orm";
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb";
import { notifyOwner } from "./_core/notification";
import { processRemerciementsAutomatiques } from "./remerciementScheduler";
/**
* Job automatique qui vérifie quotidiennement les séquences à venir
* et envoie les rappels J-7 et J-1 aux apprenants inscrits
*/
export async function processRappelsAutomatiques() {
console.log("[Rappels] Démarrage du traitement des rappels automatiques...");
const db = await getDb();
if (!db) {
console.error("[Rappels] Base de données non disponible");
return;
}
try {
// Récupérer tous les rappels actifs
const rappelsActifs = await db
.select()
.from(rappels)
.where(eq(rappels.actif, true));
console.log(`[Rappels] ${rappelsActifs.length} rappel(s) actif(s) trouvé(s)`);
for (const rappel of rappelsActifs) {
await processRappel(rappel);
}
console.log("[Rappels] Traitement terminé");
} catch (error) {
console.error("[Rappels] Erreur lors du traitement des rappels:", error);
}
}
async function processRappel(rappel: any) {
console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`);
const db = await getDb();
if (!db) return;
try {
// Calculer la date cible selon le timing (pré ou post-formation)
const dateCible = new Date();
if (rappel.timing === 'pre_formation') {
dateCible.setDate(dateCible.getDate() + rappel.joursAvant);
} else {
dateCible.setDate(dateCible.getDate() - rappel.joursAvant);
}
dateCible.setHours(0, 0, 0, 0);
const dateCibleFin = new Date(dateCible);
dateCibleFin.setHours(23, 59, 59, 999);
console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`);
// Vérifier si ce rappel est associé à des dates spécifiques
const datesAssociees = rappel.dateFormationIds || [];
const filtreDates = datesAssociees.length > 0;
console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`);
// Récupérer les dates de formation concernées
let datesConcernees;
if (filtreDates) {
// Seulement les dates associées à ce rappel
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
inArray(datesFormation.id, datesAssociees),
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
);
} else {
// Toutes les dates correspondant à la date cible
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
);
}
console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`);
// Grouper les dates par séquence pour envoyer un seul email par apprenant
const sequencesMap = new Map<number, { sequence: any; formation: any; formateur: any; dates: any[] }>();
for (const { dateFormation, sequence, formation, formateur } of datesConcernees) {
if (!dateFormation || !sequence || !formation) continue;
if (!sequencesMap.has(sequence.id)) {
sequencesMap.set(sequence.id, {
sequence,
formation,
formateur,
dates: [],
});
}
sequencesMap.get(sequence.id)!.dates.push(dateFormation);
}
console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`);
for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) {
if (!sequence || !formation) continue;
// Récupérer toutes les dates de cette séquence pour l'email
const toutesLesDates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequence.id))
.orderBy(datesFormation.ordre);
const formateurNom = formateur?.nom || "";
// Récupérer tous les apprenants inscrits à cette séquence
const inscrits = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(
and(
eq(inscriptions.sequenceId, sequence.id),
eq(inscriptions.statut, "confirmee")
)
);
console.log(`[Rappels] ${inscrits.length} apprenant(s) inscrit(s) à la séquence ${sequence.nom}`);
// Envoyer le rappel à chaque apprenant
let nbEnvoyes = 0;
let nbEchecs = 0;
const echecs: Array<{ email: string; erreur: string }> = [];
for (const { apprenant } of inscrits) {
if (!apprenant || !apprenant.email) continue;
// Vérifier si le rappel a déjà été envoyé
const dejaEnvoye = await rappelDejaEnvoye(rappel.id, sequence.id, apprenant.id);
if (dejaEnvoye) {
console.log(`[Rappels] Rappel déjà envoyé à ${apprenant.email} pour la séquence ${sequence.nom} - ignoré`);
continue;
}
try {
// Choisir la fonction d'envoi selon le type de rappel
if (rappel.templateType === "rappel") {
// Rappel J-7
await sendRappelJ7Email({
apprenantEmail: apprenant.email,
apprenantPrenom: apprenant.prenom,
apprenantNom: apprenant.nom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: sequence.lieu || "",
formateur: formateurNom,
attachmentUrl: rappel.urlFichier || undefined,
attachmentFilename: rappel.nomFichier || undefined,
attachmentMimeType: rappel.typeFichier || undefined,
});
console.log(`[Rappels] Email J-7 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
} else if (rappel.templateType === "rappelJ1") {
// Rappel J-1
await sendRappelJ1Email({
apprenantEmail: apprenant.email,
apprenantPrenom: apprenant.prenom,
apprenantNom: apprenant.nom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: sequence.lieu || "",
formateur: formateurNom,
attachmentUrl: rappel.urlFichier || undefined,
attachmentFilename: rappel.nomFichier || undefined,
attachmentMimeType: rappel.typeFichier || undefined,
});
console.log(`[Rappels] Email J-1 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
}
// Logger le succès
await logRappelEnvoye({
rappelId: rappel.id,
sequenceId: sequence.id,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
typeRappel: rappel.templateType,
statut: "success",
dateSequence: toutesLesDates[0].dateDebut,
});
nbEnvoyes++;
} catch (emailError: any) {
const messageErreur = emailError?.message || String(emailError);
console.error(`[Rappels] Erreur lors de l'envoi à ${apprenant.email}:`, emailError);
// Logger l'échec
await logRappelEnvoye({
rappelId: rappel.id,
sequenceId: sequence.id,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
typeRappel: rappel.templateType,
statut: "failed",
messageErreur: messageErreur,
dateSequence: toutesLesDates[0].dateDebut,
});
nbEchecs++;
echecs.push({ email: apprenant.email, erreur: messageErreur });
}
}
// Notifier l'administrateur en cas d'échecs
if (nbEchecs > 0) {
const listeEchecs = echecs.map(e => `- ${e.email}: ${e.erreur}`).join("\n");
await notifyOwner({
title: `⚠️ Échecs d'envoi de rappels`,
content: `${nbEchecs} rappel(s) n'ont pas pu être envoyés pour la séquence "${sequence.nom}" (${formation.nom}):\n\n${listeEchecs}\n\nRappel: ${rappel.nom} (J-${rappel.joursAvant})\nEnvois réussis: ${nbEnvoyes}`,
});
}
console.log(`[Rappels] Séquence ${sequence.nom}: ${nbEnvoyes} envoi(s) réussi(s), ${nbEchecs} échec(s)`);
}
// Mettre à jour la date de dernière exécution
await db
.update(rappels)
.set({ derniereExecution: new Date() })
.where(eq(rappels.id, rappel.id));
} catch (error) {
console.error(`[Rappels] Erreur lors du traitement du rappel ${rappel.nom}:`, error);
}
}
/**
* Initialise le scheduler pour exécuter les rappels automatiques
* Vérifie toutes les heures s'il y a des rappels à envoyer
*/
export function initRappelScheduler() {
console.log("[Rappels] Initialisation du scheduler de rappels automatiques");
// Exécuter immédiatement au démarrage
processRappelsAutomatiques();
processRemerciementsAutomatiques();
// Puis exécuter toutes les heures
setInterval(() => {
processRappelsAutomatiques();
processRemerciementsAutomatiques();
}, 60 * 60 * 1000); // 1 heure en millisecondes
console.log("[Rappels] Scheduler initialisé - vérification toutes les heures (rappels + remerciements)");
}

View File

@@ -0,0 +1,351 @@
import { getDb } from "./db";
import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs, questionnaires, envoisQuestionnaires, logsNotifications } from "../drizzle/schema";
import { eq, and, lte, sql } from "drizzle-orm";
import { sendRemerciementPostFormation } from "./emailService";
import { logNotification } from "./notificationLogsDb";
import crypto from "crypto";
/**
* Génère un token unique pour accéder au questionnaire
*/
function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
/**
* Récupère ou crée un lien questionnaire pour un apprenant
*/
async function getOrCreateQuestionnaireLink(
apprenantId: number,
sequenceId: number
): Promise<string | null> {
const db = await getDb();
if (!db) return null;
try {
// Chercher un questionnaire de satisfaction actif
const [questionnaire] = await db
.select()
.from(questionnaires)
.where(
and(
eq(questionnaires.type, "satisfaction"),
eq(questionnaires.actif, true)
)
)
.limit(1);
if (!questionnaire) {
console.log("[Remerciements] Aucun questionnaire de satisfaction actif trouvé");
return null;
}
// Vérifier si un envoi existe déjà
const [envoiExistant] = await db
.select()
.from(envoisQuestionnaires)
.where(
and(
eq(envoisQuestionnaires.questionnaireId, questionnaire.id),
eq(envoisQuestionnaires.apprenantId, apprenantId),
eq(envoisQuestionnaires.sequenceId, sequenceId)
)
)
.limit(1);
if (envoiExistant) {
// Retourner le lien existant
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
return `${baseUrl}/questionnaire/${envoiExistant.token}`;
}
// Créer un nouvel envoi
const token = generateToken();
await db.insert(envoisQuestionnaires).values({
questionnaireId: questionnaire.id,
apprenantId,
sequenceId,
token,
dateEnvoi: new Date(),
dateReponse: null,
});
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
return `${baseUrl}/questionnaire/${token}`;
} catch (error) {
console.error("[Remerciements] Erreur lors de la création du lien questionnaire:", error);
return null;
}
}
/**
* Vérifie si un remerciement a déjà été envoyé pour une inscription en vérifiant dans la base de données
*/
async function remerciementDejaEnvoye(apprenantId: number, sequenceId: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
try {
const [existing] = await db
.select({ id: logsNotifications.id })
.from(logsNotifications)
.where(
and(
eq(logsNotifications.type, "remerciement"),
eq(logsNotifications.apprenantId, apprenantId),
eq(logsNotifications.sequenceId, sequenceId),
eq(logsNotifications.statut, "success")
)
)
.limit(1);
return !!existing;
} catch (error) {
console.error("[Remerciements] Erreur lors de la vérification du log:", error);
return false;
}
}
/**
* Job automatique qui vérifie quotidiennement les séquences terminées
* et envoie les emails de remerciement aux apprenants
*/
export async function processRemerciementsAutomatiques() {
console.log("[Remerciements] Démarrage du traitement des remerciements post-formation...");
const db = await getDb();
if (!db) {
console.error("[Remerciements] Base de données non disponible");
return;
}
try {
const now = new Date();
const hier = new Date();
hier.setDate(hier.getDate() - 1);
hier.setHours(23, 59, 59, 999);
// Récupérer les séquences dont la dernière date est passée (terminées hier ou avant)
const sequencesTerminees = await db
.select({
sequence: sequences,
formation: formations,
formateur: formateurs,
derniereDate: sql<Date>`MAX(${datesFormation.dateFin})`.as('derniereDate'),
})
.from(sequences)
.innerJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.innerJoin(datesFormation, eq(sequences.id, datesFormation.sequenceId))
.where(eq(sequences.statut, 'ouverte')) // Séquences encore ouvertes (pas encore marquées terminées)
.groupBy(sequences.id, formations.id, formateurs.id)
.having(lte(sql`MAX(${datesFormation.dateFin})`, hier));
console.log(`[Remerciements] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s)`);
for (const { sequence, formation, formateur, derniereDate } of sequencesTerminees) {
await processRemerciementSequence(sequence, formation, formateur, derniereDate);
}
console.log("[Remerciements] Traitement terminé");
} catch (error) {
console.error("[Remerciements] Erreur lors du traitement des remerciements:", error);
}
}
async function processRemerciementSequence(
sequence: any,
formation: any,
formateur: any | null,
derniereDate: Date
) {
console.log(`[Remerciements] Traitement de la séquence: ${sequence.nom} (terminée le ${derniereDate})`);
const db = await getDb();
if (!db) return;
try {
// Récupérer les inscriptions confirmées pour cette séquence
const inscriptionsConfirmees = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(
and(
eq(inscriptions.sequenceId, sequence.id),
eq(inscriptions.statut, 'confirmee')
)
);
console.log(`[Remerciements] ${inscriptionsConfirmees.length} inscription(s) confirmée(s) pour ${sequence.nom}`);
let envoyesCount = 0;
let errorsCount = 0;
for (const { inscription, apprenant } of inscriptionsConfirmees) {
// Vérifier si le remerciement a déjà été envoyé (vérification en base de données)
const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequence.id);
if (dejaEnvoye) {
console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}`);
continue;
}
try {
// Générer le lien questionnaire
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequence.id);
await sendRemerciementPostFormation({
apprenantEmail: apprenant.email,
apprenantNom: apprenant.nom,
apprenantPrenom: apprenant.prenom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
formateurNom: formateur?.nom,
lienQuestionnaire: lienQuestionnaire || undefined,
});
// Logger la notification (sert aussi de marqueur pour éviter les doublons)
await logNotification({
type: "remerciement",
sequenceId: sequence.id,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
sujet: `Merci pour votre participation - ${formation.nom}`,
statut: "success",
metadata: { lienQuestionnaire },
});
envoyesCount++;
console.log(`[Remerciements] Email envoyé à ${apprenant.email}`);
} catch (error: any) {
// Logger l'échec
await logNotification({
type: "remerciement",
sequenceId: sequence.id,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
sujet: `Merci pour votre participation - ${formation.nom}`,
statut: "failed",
messageErreur: error.message,
});
errorsCount++;
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
}
}
console.log(`[Remerciements] Séquence ${sequence.nom}: ${envoyesCount} envoyé(s), ${errorsCount} erreur(s)`);
// Optionnel: Marquer la séquence comme terminée
// await db.update(sequences).set({ statut: 'terminee' }).where(eq(sequences.id, sequence.id));
} catch (error) {
console.error(`[Remerciements] Erreur pour la séquence ${sequence.nom}:`, error);
}
}
/**
* Envoie manuellement un email de remerciement pour une séquence spécifique
* Utilisé par l'interface admin pour envoyer les remerciements à la demande
*/
export async function envoyerRemerciementsSequence(sequenceId: number): Promise<{ sent: number; failed: number }> {
const db = await getDb();
if (!db) {
throw new Error("Base de données non disponible");
}
// Récupérer la séquence avec formation et formateur
const sequenceData = await db
.select({
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(sequences)
.innerJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(eq(sequences.id, sequenceId))
.limit(1);
if (sequenceData.length === 0) {
throw new Error("Séquence introuvable");
}
const { sequence, formation, formateur } = sequenceData[0];
// Récupérer les inscriptions confirmées
const inscriptionsConfirmees = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(
and(
eq(inscriptions.sequenceId, sequenceId),
eq(inscriptions.statut, 'confirmee')
)
);
let sent = 0;
let failed = 0;
for (const { inscription, apprenant } of inscriptionsConfirmees) {
// Vérifier si le remerciement a déjà été envoyé
const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequenceId);
if (dejaEnvoye) {
console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}, ignoré`);
continue;
}
try {
// Générer le lien questionnaire
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequenceId);
await sendRemerciementPostFormation({
apprenantEmail: apprenant.email,
apprenantNom: apprenant.nom,
apprenantPrenom: apprenant.prenom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
formateurNom: formateur?.nom,
lienQuestionnaire: lienQuestionnaire || undefined,
});
// Logger la notification
await logNotification({
type: "remerciement",
sequenceId,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
sujet: `Merci pour votre participation - ${formation.nom}`,
statut: "success",
metadata: { lienQuestionnaire },
});
sent++;
} catch (error: any) {
// Logger l'échec
await logNotification({
type: "remerciement",
sequenceId,
apprenantId: apprenant.id,
emailDestinataire: apprenant.email,
sujet: `Merci pour votre participation - ${formation.nom}`,
statut: "failed",
messageErreur: error.message,
});
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
failed++;
}
}
return { sent, failed };
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
// Preconfigured storage helpers for Manus WebDev templates
// Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
import { ENV } from './_core/env';
type StorageConfig = { baseUrl: string; apiKey: string };
function getStorageConfig(): StorageConfig {
const baseUrl = ENV.forgeApiUrl;
const apiKey = ENV.forgeApiKey;
if (!baseUrl || !apiKey) {
throw new Error(
"Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
);
}
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
}
function buildUploadUrl(baseUrl: string, relKey: string): URL {
const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
url.searchParams.set("path", normalizeKey(relKey));
return url;
}
async function buildDownloadUrl(
baseUrl: string,
relKey: string,
apiKey: string
): Promise<string> {
const downloadApiUrl = new URL(
"v1/storage/downloadUrl",
ensureTrailingSlash(baseUrl)
);
downloadApiUrl.searchParams.set("path", normalizeKey(relKey));
const response = await fetch(downloadApiUrl, {
method: "GET",
headers: buildAuthHeaders(apiKey),
});
return (await response.json()).url;
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
function normalizeKey(relKey: string): string {
return relKey.replace(/^\/+/, "");
}
function toFormData(
data: Buffer | Uint8Array | string,
contentType: string,
fileName: string
): FormData {
const blob =
typeof data === "string"
? new Blob([data], { type: contentType })
: new Blob([data as any], { type: contentType });
const form = new FormData();
form.append("file", blob, fileName || "file");
return form;
}
function buildAuthHeaders(apiKey: string): HeadersInit {
return { Authorization: `Bearer ${apiKey}` };
}
export async function storagePut(
relKey: string,
data: Buffer | Uint8Array | string,
contentType = "application/octet-stream"
): Promise<{ key: string; url: string }> {
const { baseUrl, apiKey } = getStorageConfig();
const key = normalizeKey(relKey);
const uploadUrl = buildUploadUrl(baseUrl, key);
const formData = toFormData(data, contentType, key.split("/").pop() ?? key);
const response = await fetch(uploadUrl, {
method: "POST",
headers: buildAuthHeaders(apiKey),
body: formData,
});
if (!response.ok) {
const message = await response.text().catch(() => response.statusText);
throw new Error(
`Storage upload failed (${response.status} ${response.statusText}): ${message}`
);
}
const url = (await response.json()).url;
return { key, url };
}
export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> {
const { baseUrl, apiKey } = getStorageConfig();
const key = normalizeKey(relKey);
return {
key,
url: await buildDownloadUrl(baseUrl, key, apiKey),
};
}