Checkpoint: Implémentation de l'émargement formateur par QR code avec signature tactile

Modifications principales :
- Ajout des champs typeUtilisateur et formateurId dans la table presences pour distinguer formateurs et apprenants
- Détection automatique du rôle (formateur/apprenant) via l'email lors du scan du QR code
- Interface de signature tactile réutilisée pour les formateurs
- Intégration des signatures tactiles formateur dans la génération PDF de la feuille de présence
- QR code dans l'espace formateur accessible pour l'émargement
- Tests unitaires validés avec succès

Le système permet maintenant aux formateurs d'émarger (matin et après-midi) via le même QR code que les apprenants, avec détection automatique du rôle et intégration des signatures dans le PDF.
This commit is contained in:
Manus
2026-02-02 13:24:08 -05:00
parent eebcb64848
commit e8410230a9
11 changed files with 2765 additions and 75 deletions

View File

@@ -1156,7 +1156,7 @@ export const appRouter = router({
}
// Récupérer toutes les présences avec signatures pour cette séquence
const { getPresencesBySequence } = await import("./presenceDb");
const { getPresencesBySequence, getPresencesFormateurBySequence } = await import("./presenceDb");
const presences = await getPresencesBySequence(input.sequenceId);
const data = inscriptions
@@ -1180,6 +1180,9 @@ export const appRouter = router({
};
});
// Récupérer les présences du formateur
const presencesFormateur = await getPresencesFormateurBySequence(input.sequenceId);
const buffer = await generateFeuillePresence({
formationNom: formation.nom,
sequenceNom: sequence.nom,
@@ -1193,6 +1196,11 @@ export const appRouter = router({
publicCible: sequence.publicCible,
formateur: formateurNom,
apprenants: data,
presencesFormateur: presencesFormateur.map(p => ({
dateFormationId: p.dateFormationId,
periode: p.periode as 'matin' | 'apres_midi',
signatureUrl: p.signatureUrl,
})),
});
return {
@@ -2513,7 +2521,8 @@ export const appRouter = router({
valider: publicProcedure
.input(z.object({
token: z.string().optional(),
inscriptionId: z.number(),
email: z.string().email(),
inscriptionId: z.number().optional(),
dateFormationId: z.number(),
periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]),
@@ -2522,57 +2531,77 @@ export const appRouter = router({
signatureDataUrl: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { validerPresence } = await import("./presenceDb");
const { validerPresence, validerPresenceFormateur } = await import("./presenceDb");
const { getDb } = await import("./db");
const { sequences, inscriptions } = await import("../drizzle/schema");
const { sequences, inscriptions, formateurs } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Détecter si l'utilisateur est un formateur
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
const isFormateur = formateurData.length > 0;
// Si mode QR code, vérifier le token
let sequenceId: number | undefined;
if (input.modeValidation === "qrcode" && input.token) {
// Récupérer l'inscription pour vérifier la séquence
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscription.length === 0) {
throw new Error("Inscription introuvable");
}
// Vérifier que le token correspond à la séquence
const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1);
if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) {
// Vérifier que le token correspond à une séquence
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1);
if (sequence.length === 0) {
throw new Error("QR code invalide");
}
sequenceId = sequence[0].id;
// Si apprenant, vérifier l'inscription
if (!isFormateur && input.inscriptionId) {
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscription.length === 0) {
throw new Error("Inscription introuvable");
}
if (inscription[0].sequenceId !== sequenceId) {
throw new Error("QR code invalide pour cette inscription");
}
}
}
// Stocker la signature localement si fournie
let signatureUrl: string | undefined;
let signatureS3Key: string | undefined;
let finalSequenceId = sequenceId;
if (input.signatureDataUrl) {
const fs = await import("fs");
const path = await import("path");
const crypto = await import("crypto");
// Récupérer l'inscription pour obtenir la séquence
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length === 0) throw new Error("Inscription introuvable");
// Déterminer la séquence
if (!finalSequenceId) {
if (input.inscriptionId) {
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length === 0) throw new Error("Inscription introuvable");
finalSequenceId = inscriptionData[0].sequenceId;
} else {
throw new Error("Impossible de déterminer la séquence");
}
}
const sequenceData = await db.select().from(sequences).where(eq(sequences.id, inscriptionData[0].sequenceId)).limit(1);
const sequenceData = await db.select().from(sequences).where(eq(sequences.id, finalSequenceId)).limit(1);
if (sequenceData.length === 0) throw new Error("Séquence introuvable");
const formationId = sequenceData[0].formationId;
const sequenceId = sequenceData[0].id;
// Créer la structure de dossiers
const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`);
const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${finalSequenceId}`);
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Générer un nom de fichier unique
const randomSuffix = crypto.randomBytes(8).toString("hex");
const fileName = `signature-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`;
const userType = isFormateur ? "formateur" : "apprenant";
const userId = isFormateur ? formateurData[0].id : input.inscriptionId;
const fileName = `signature-${userType}-${userId}-${input.dateFormationId}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName);
// Convertir le data URL en buffer et sauvegarder
@@ -2581,57 +2610,75 @@ export const appRouter = router({
fs.writeFileSync(filePath, buffer);
// Générer l'URL publique
signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`;
signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
signatureUrl = `/uploads/signatures/${formationId}-${finalSequenceId}/${fileName}`;
signatureS3Key = `signatures/${formationId}-${finalSequenceId}/${fileName}`;
}
const result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
periode: input.periode,
modeValidation: input.modeValidation,
validateurId: input.validateurId,
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
});
let result;
if (isFormateur) {
// Valider la présence du formateur
if (!finalSequenceId) throw new Error("Impossible de déterminer la séquence");
result = await validerPresenceFormateur({
formateurId: formateurData[0].id,
sequenceId: finalSequenceId,
dateFormationId: input.dateFormationId,
periode: input.periode,
modeValidation: input.modeValidation,
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
});
} else {
// Valider la présence de l'apprenant
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
periode: input.periode,
modeValidation: input.modeValidation,
validateurId: input.validateurId,
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
});
// Vérifier si toutes les présences sont validées
const { checkAllPresencesValidated } = await import("./presenceDb");
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
// Vérifier si toutes les présences sont validées
const { checkAllPresencesValidated } = await import("./presenceDb");
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
// Si toutes les présences sont validées, générer l'attestation automatiquement
if (allValidated) {
try {
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Récupérer les données de l'inscription
const db = await getDb();
if (db) {
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length > 0) {
// Enregistrer l'attestation en base
await enregistrerAttestation(
input.inscriptionId,
inscriptionData[0].apprenantId,
inscriptionData[0].sequenceId,
s3Key,
pdfUrl
);
// Si toutes les présences sont validées, générer l'attestation automatiquement
if (allValidated) {
try {
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Récupérer les données de l'inscription
const db = await getDb();
if (db) {
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length > 0) {
// Enregistrer l'attestation en base
await enregistrerAttestation(
input.inscriptionId,
inscriptionData[0].apprenantId,
inscriptionData[0].sequenceId,
s3Key,
pdfUrl
);
}
}
}
return {
...result,
attestationGenerated: true,
attestationUrl: pdfUrl,
};
} catch (error) {
console.error("Erreur lors de la génération de l'attestation:", error);
// Ne pas bloquer la validation de présence si l'attestation échoue
return {
...result,
attestationGenerated: true,
attestationUrl: pdfUrl,
};
} catch (error) {
console.error("Erreur lors de la génération de l'attestation:", error);
// Ne pas bloquer la validation de présence si l'attestation échoue
}
}
}