Checkpoint: Corrections multiples :
- Renommage questionnaire.nom → questionnaire.titre dans QuestionnaireReponse.tsx, AdminQuestionnaireEdit.tsx, questionnaireScheduler.ts, questionnaireExport.ts - Correction des requêtes SQL dans questionnaireDb.ts (suppression de la condition complete inexistante) - Ajout des colonnes valeurMin et valeurMax dans la table questions - Renommage de la colonne nom en titre dans la table questionnaires
This commit is contained in:
106
deployment-package/source_server/__tests__/localAuth.test.ts
Normal file
106
deployment-package/source_server/__tests__/localAuth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
32
deployment-package/source_server/_core/appUrl.ts
Normal file
32
deployment-package/source_server/_core/appUrl.ts
Normal 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}`;
|
||||
}
|
||||
28
deployment-package/source_server/_core/context.ts
Normal file
28
deployment-package/source_server/_core/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
deployment-package/source_server/_core/cookies.ts
Normal file
48
deployment-package/source_server/_core/cookies.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
64
deployment-package/source_server/_core/dataApi.ts
Normal file
64
deployment-package/source_server/_core/dataApi.ts
Normal 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;
|
||||
}
|
||||
206
deployment-package/source_server/_core/emailSender.ts
Normal file
206
deployment-package/source_server/_core/emailSender.ts
Normal 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;
|
||||
}
|
||||
11
deployment-package/source_server/_core/env.ts
Normal file
11
deployment-package/source_server/_core/env.ts
Normal 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 ?? "",
|
||||
};
|
||||
59
deployment-package/source_server/_core/fileUpload.ts
Normal file
59
deployment-package/source_server/_core/fileUpload.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
92
deployment-package/source_server/_core/imageGeneration.ts
Normal file
92
deployment-package/source_server/_core/imageGeneration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
82
deployment-package/source_server/_core/index.ts
Normal file
82
deployment-package/source_server/_core/index.ts
Normal 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);
|
||||
332
deployment-package/source_server/_core/llm.ts
Normal file
332
deployment-package/source_server/_core/llm.ts
Normal 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;
|
||||
}
|
||||
102
deployment-package/source_server/_core/localAuth.ts
Normal file
102
deployment-package/source_server/_core/localAuth.ts
Normal 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;
|
||||
319
deployment-package/source_server/_core/map.ts
Normal file
319
deployment-package/source_server/_core/map.ts
Normal 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
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
114
deployment-package/source_server/_core/notification.ts
Normal file
114
deployment-package/source_server/_core/notification.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
53
deployment-package/source_server/_core/oauth.ts
Normal file
53
deployment-package/source_server/_core/oauth.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
330
deployment-package/source_server/_core/sdk.ts
Normal file
330
deployment-package/source_server/_core/sdk.ts
Normal 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();
|
||||
103
deployment-package/source_server/_core/smtpSender.ts
Normal file
103
deployment-package/source_server/_core/smtpSender.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
deployment-package/source_server/_core/systemRouter.ts
Normal file
29
deployment-package/source_server/_core/systemRouter.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
45
deployment-package/source_server/_core/trpc.ts
Normal file
45
deployment-package/source_server/_core/trpc.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
6
deployment-package/source_server/_core/types/cookie.d.ts
vendored
Normal file
6
deployment-package/source_server/_core/types/cookie.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "cookie" {
|
||||
export function parse(
|
||||
str: string,
|
||||
options?: Record<string, unknown>
|
||||
): Record<string, string>;
|
||||
}
|
||||
69
deployment-package/source_server/_core/types/manusTypes.ts
Normal file
69
deployment-package/source_server/_core/types/manusTypes.ts
Normal 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;
|
||||
}
|
||||
67
deployment-package/source_server/_core/uploadFile.ts
Normal file
67
deployment-package/source_server/_core/uploadFile.ts
Normal 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;
|
||||
67
deployment-package/source_server/_core/uploadImage.ts
Normal file
67
deployment-package/source_server/_core/uploadImage.ts
Normal 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;
|
||||
67
deployment-package/source_server/_core/vite.ts
Normal file
67
deployment-package/source_server/_core/vite.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
284
deployment-package/source_server/_core/voiceTranscription.ts
Normal file
284
deployment-package/source_server/_core/voiceTranscription.ts
Normal 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;
|
||||
* }),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
147
deployment-package/source_server/analyticsDb.ts
Normal file
147
deployment-package/source_server/analyticsDb.ts
Normal 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;
|
||||
}
|
||||
145
deployment-package/source_server/analyticsExportService.ts
Normal file
145
deployment-package/source_server/analyticsExportService.ts
Normal 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");
|
||||
}
|
||||
430
deployment-package/source_server/attestationService.ts
Normal file
430
deployment-package/source_server/attestationService.ts
Normal 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 };
|
||||
}
|
||||
93
deployment-package/source_server/attestationsDb.ts
Normal file
93
deployment-package/source_server/attestationsDb.ts
Normal 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;
|
||||
}
|
||||
60
deployment-package/source_server/dateUtils.ts
Normal file
60
deployment-package/source_server/dateUtils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utilitaires pour la gestion des dates
|
||||
* Gère correctement les conversions entre heure locale et UTC
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une chaîne datetime-local (format: "2025-01-15T14:30") en Date
|
||||
* en construisant la date avec une chaîne ISO qui force l'heure locale
|
||||
* Préserve l'heure locale sans conversion UTC
|
||||
*/
|
||||
export function parseLocalDateTime(dateTimeString: string): Date {
|
||||
if (!dateTimeString) {
|
||||
throw new Error('Date string is required');
|
||||
}
|
||||
|
||||
// Le format datetime-local est "YYYY-MM-DDTHH:mm"
|
||||
const match = dateTimeString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid date format: ${dateTimeString}. Expected YYYY-MM-DDTHH:mm`);
|
||||
}
|
||||
|
||||
const [, year, month, day, hours, minutes] = match;
|
||||
|
||||
// Créer un objet Date en utilisant le constructeur avec composants
|
||||
// MySQL stocke en UTC, donc on doit compenser le décalage horaire
|
||||
const date = new Date(
|
||||
parseInt(year),
|
||||
parseInt(month) - 1,
|
||||
parseInt(day),
|
||||
parseInt(hours),
|
||||
parseInt(minutes),
|
||||
0
|
||||
);
|
||||
|
||||
// Compenser le décalage UTC en ajoutant le décalage du fuseau horaire
|
||||
// Cela garantit que l'heure stockée en UTC correspond à l'heure locale saisie
|
||||
const offset = date.getTimezoneOffset(); // en minutes (négatif pour Europe/Paris)
|
||||
date.setMinutes(date.getMinutes() - offset);
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid date string: ${dateTimeString}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une Date en chaîne datetime-local pour les inputs HTML
|
||||
* Format: "YYYY-MM-DDTHH:mm"
|
||||
*/
|
||||
export function formatToLocalDateTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
1272
deployment-package/source_server/db.ts
Normal file
1272
deployment-package/source_server/db.ts
Normal file
File diff suppressed because it is too large
Load Diff
177
deployment-package/source_server/emailPreview.ts
Normal file
177
deployment-package/source_server/emailPreview.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
1023
deployment-package/source_server/emailService.ts
Normal file
1023
deployment-package/source_server/emailService.ts
Normal file
File diff suppressed because it is too large
Load Diff
180
deployment-package/source_server/emailTemplateGenerator.ts
Normal file
180
deployment-package/source_server/emailTemplateGenerator.ts
Normal 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();
|
||||
}
|
||||
66
deployment-package/source_server/emailTemplateUtils.ts
Normal file
66
deployment-package/source_server/emailTemplateUtils.ts
Normal 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);
|
||||
}
|
||||
110
deployment-package/source_server/etablissementsDb.ts
Normal file
110
deployment-package/source_server/etablissementsDb.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
277
deployment-package/source_server/exportService.ts
Normal file
277
deployment-package/source_server/exportService.ts
Normal 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'));
|
||||
}
|
||||
402
deployment-package/source_server/formateurDb.ts
Normal file
402
deployment-package/source_server/formateurDb.ts
Normal 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;
|
||||
}
|
||||
202
deployment-package/source_server/gestionAttestationsDb.ts
Normal file
202
deployment-package/source_server/gestionAttestationsDb.ts
Normal 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;
|
||||
}
|
||||
109
deployment-package/source_server/icsGenerator.ts
Normal file
109
deployment-package/source_server/icsGenerator.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Module de génération de fichiers ICS (iCalendar) pour les invitations Outlook
|
||||
*/
|
||||
|
||||
interface ICSEvent {
|
||||
summary: string;
|
||||
description?: string;
|
||||
location: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
attendeeEmail: string;
|
||||
attendeeName: string;
|
||||
organizerEmail?: string;
|
||||
organizerName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date au format iCalendar (YYYYMMDDTHHMMSSZ)
|
||||
*/
|
||||
function formatICSDate(date: Date): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UID unique pour l'événement
|
||||
*/
|
||||
function generateUID(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@itinova.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux pour le format ICS
|
||||
*/
|
||||
function escapeICS(text: string): string {
|
||||
return text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une invitation Outlook
|
||||
*/
|
||||
export function generateICS(event: ICSEvent): string {
|
||||
const now = new Date();
|
||||
const uid = generateUID();
|
||||
|
||||
const icsContent = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Itinova//Formation Manager//FR',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:REQUEST',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid}`,
|
||||
`DTSTAMP:${formatICSDate(now)}`,
|
||||
`DTSTART:${formatICSDate(event.startDate)}`,
|
||||
`DTEND:${formatICSDate(event.endDate)}`,
|
||||
`SUMMARY:${escapeICS(event.summary)}`,
|
||||
event.description ? `DESCRIPTION:${escapeICS(event.description)}` : '',
|
||||
`LOCATION:${escapeICS(event.location)}`,
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:OPAQUE', // Marque comme "occupé" dans le calendrier
|
||||
'SEQUENCE:0',
|
||||
`ORGANIZER;CN=${escapeICS(event.organizerName || 'Formation Itinova')}:mailto:${event.organizerEmail || 'formation@itinova.com'}`,
|
||||
`ATTENDEE;CN=${escapeICS(event.attendeeName)};RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:${event.attendeeEmail}`,
|
||||
'BEGIN:VALARM',
|
||||
'TRIGGER:-P1D', // Rappel 1 jour avant
|
||||
'ACTION:DISPLAY',
|
||||
`DESCRIPTION:Rappel: ${escapeICS(event.summary)}`,
|
||||
'END:VALARM',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].filter(line => line !== '').join('\r\n');
|
||||
|
||||
return icsContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un fichier ICS pour une session de formation
|
||||
*/
|
||||
export function generateFormationICS(params: {
|
||||
formationNom: string;
|
||||
sessionNom: string;
|
||||
dateDebut: Date;
|
||||
dateFin: Date;
|
||||
lieu: string;
|
||||
apprenantNom: string;
|
||||
apprenantPrenom: string;
|
||||
apprenantEmail: string;
|
||||
}): string {
|
||||
return generateICS({
|
||||
summary: `Formation: ${params.formationNom} - ${params.sessionNom}`,
|
||||
description: `Vous êtes inscrit à la formation "${params.formationNom}".\n\nSession: ${params.sessionNom}\n\nMerci de vous présenter à l'heure indiquée.`,
|
||||
location: params.lieu,
|
||||
startDate: params.dateDebut,
|
||||
endDate: params.dateFin,
|
||||
attendeeEmail: params.apprenantEmail,
|
||||
attendeeName: `${params.apprenantPrenom} ${params.apprenantNom}`,
|
||||
});
|
||||
}
|
||||
331
deployment-package/source_server/importExcel.ts
Normal file
331
deployment-package/source_server/importExcel.ts
Normal 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);
|
||||
}
|
||||
85
deployment-package/source_server/inscriptionWithDates.ts
Normal file
85
deployment-package/source_server/inscriptionWithDates.ts
Normal 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;
|
||||
}
|
||||
210
deployment-package/source_server/notificationLogsDb.ts
Normal file
210
deployment-package/source_server/notificationLogsDb.ts
Normal 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",
|
||||
};
|
||||
134
deployment-package/source_server/parametresDb.ts
Normal file
134
deployment-package/source_server/parametresDb.ts
Normal 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'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
149
deployment-package/source_server/presenceDb.ts
Normal file
149
deployment-package/source_server/presenceDb.ts
Normal 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 };
|
||||
}
|
||||
63
deployment-package/source_server/qrCodeGenerator.ts
Normal file
63
deployment-package/source_server/qrCodeGenerator.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
250
deployment-package/source_server/questionnaireDb.ts
Normal file
250
deployment-package/source_server/questionnaireDb.ts
Normal 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;
|
||||
}
|
||||
174
deployment-package/source_server/questionnaireExport.ts
Normal file
174
deployment-package/source_server/questionnaireExport.ts
Normal 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'));
|
||||
}
|
||||
248
deployment-package/source_server/questionnaireScheduler.ts
Normal file
248
deployment-package/source_server/questionnaireScheduler.ts
Normal 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;
|
||||
}
|
||||
164
deployment-package/source_server/questionnaireSuiviDb.ts
Normal file
164
deployment-package/source_server/questionnaireSuiviDb.ts
Normal 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
|
||||
};
|
||||
}
|
||||
328
deployment-package/source_server/rappelDb.ts
Normal file
328
deployment-package/source_server/rappelDb.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
226
deployment-package/source_server/rappelRetry.ts
Normal file
226
deployment-package/source_server/rappelRetry.ts
Normal 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");
|
||||
}
|
||||
294
deployment-package/source_server/rappelScheduler.ts
Normal file
294
deployment-package/source_server/rappelScheduler.ts
Normal 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)");
|
||||
}
|
||||
351
deployment-package/source_server/remerciementScheduler.ts
Normal file
351
deployment-package/source_server/remerciementScheduler.ts
Normal 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 };
|
||||
}
|
||||
2380
deployment-package/source_server/routers.ts
Normal file
2380
deployment-package/source_server/routers.ts
Normal file
File diff suppressed because it is too large
Load Diff
102
deployment-package/source_server/storage.ts
Normal file
102
deployment-package/source_server/storage.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user