Checkpoint: Implémentation du QR code dédié pour l'émargement formateur avec signature tactile

- Création de la page /emargement-formateur/:token avec formulaire simplifié
- Procédure backend presences.validerFormateur dédiée
- Deux boutons QR code dans l'interface formateur (apprenants + formateur)
- Génération automatique des URL distinctes pour chaque type d'utilisateur
- Déploiement réussi sur le VPS de production
This commit is contained in:
Manus
2026-02-02 15:11:18 -05:00
parent f9983adada
commit 04001e7bcd
11 changed files with 1043 additions and 321 deletions

View File

@@ -2517,6 +2517,42 @@ export const appRouter = router({
};
}),
// Générer un QR code formateur pour une séquence
generateQRCodeFormateur: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken, generateQRCodeFormateurDataURL } = await import("./qrCodeGenerator");
const { getDb } = await import("./db");
const { sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que la séquence existe
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
if (sequence.length === 0) {
throw new Error("Séquence introuvable");
}
// Générer un nouveau token si nécessaire
let token = sequence[0].qrCodeToken;
if (!token) {
token = generateQRToken();
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
}
// Générer le QR code formateur
const qrCodeDataURL = await generateQRCodeFormateurDataURL(token);
return {
token,
qrCodeDataURL,
};
}),
// Récupérer les informations de la séquence par token (pour formateurs)
getSequenceByToken: publicProcedure
.input(z.object({
@@ -2547,15 +2583,86 @@ export const appRouter = router({
};
}),
// Valider la présence d'un formateur (QR code dédié)
validerFormateur: publicProcedure
.input(z.object({
token: z.string(),
email: z.string().email(),
periode: z.enum(["matin", "apres_midi"]),
signatureDataUrl: z.string(),
}))
.mutation(async ({ input }) => {
const { getDb } = await import("./db");
const { formateurs, sequences, datesFormation } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const { validerPresenceFormateur } = await import("./presenceDb");
const { storagePut } = await import("./storage");
const crypto = await import("crypto");
const path = await import("path");
const fs = await import("fs/promises");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que l'email correspond à un formateur
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
if (formateurData.length === 0) {
throw new Error("Aucun formateur trouvé avec cet email");
}
// Vérifier le token et récupérer la 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");
}
// Récupérer la première date de la séquence
const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequence[0].id)).orderBy(datesFormation.dateDebut).limit(1);
if (dates.length === 0) {
throw new Error("Aucune date de formation trouvée pour cette séquence");
}
// Stocker la signature localement
const formationId = sequence[0].formationId;
const sequenceId = sequence[0].id;
const uploadsDir = path.join(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`);
await fs.mkdir(uploadsDir, { recursive: true });
const randomSuffix = crypto.randomBytes(8).toString("hex");
const fileName = `signature-formateur-${formateurData[0].id}-${dates[0].id}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName);
const base64Data = input.signatureDataUrl.split(",")[1];
const buffer = Buffer.from(base64Data, "base64");
await fs.writeFile(filePath, buffer);
const signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`;
const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
// Valider la présence formateur
await validerPresenceFormateur({
formateurId: formateurData[0].id,
sequenceId: sequence[0].id,
dateFormationId: dates[0].id,
periode: input.periode,
modeValidation: "qrcode",
signatureUrl,
signatureS3Key,
});
return { success: true };
}),
// Valider une présence (scan QR code ou manuel)
valider: publicProcedure
.input(z.object({
token: z.string().optional(),
email: z.string().email(),
inscriptionId: z.number().optional(),
dateFormationId: z.number(),
dateFormationId: z.number().optional(),
dateId: z.number().optional(), // Alias pour dateFormationId
periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]),
modeValidation: z.enum(["qrcode", "manuel"]).optional().default("qrcode"),
validateurId: z.number().optional(),
commentaire: z.string().optional(),
signatureDataUrl: z.string().optional(),
@@ -2575,14 +2682,24 @@ export const appRouter = router({
// Si mode QR code, vérifier le token
let sequenceId: number | undefined;
let autoDateFormationId: number | undefined;
if (input.modeValidation === "qrcode" && input.token) {
// Vérifier que le token correspond à une séquence
const { datesFormation } = await import("../drizzle/schema");
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 formateur, récupérer automatiquement la première date de la séquence
if (isFormateur && !input.dateFormationId && !input.dateId) {
const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)).orderBy(datesFormation.dateDebut).limit(1);
if (dates.length > 0) {
autoDateFormationId = dates[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);
@@ -2631,7 +2748,8 @@ export const appRouter = router({
const randomSuffix = crypto.randomBytes(8).toString("hex");
const userType = isFormateur ? "formateur" : "apprenant";
const userId = isFormateur ? formateurData[0].id : input.inscriptionId;
const fileName = `signature-${userType}-${userId}-${input.dateFormationId}-${randomSuffix}.png`;
const dateRef = input.dateFormationId || input.dateId || "no-date";
const fileName = `signature-${userType}-${userId}-${dateRef}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName);
// Convertir le data URL en buffer et sauvegarder
@@ -2651,9 +2769,9 @@ export const appRouter = router({
result = await validerPresenceFormateur({
formateurId: formateurData[0].id,
sequenceId: finalSequenceId,
dateFormationId: input.dateFormationId,
dateFormationId: input.dateFormationId || input.dateId || undefined,
periode: input.periode,
modeValidation: input.modeValidation,
modeValidation: input.modeValidation || "qrcode",
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
@@ -2661,11 +2779,13 @@ export const appRouter = router({
} else {
// Valider la présence de l'apprenant
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
const dateFormId = input.dateFormationId || input.dateId;
if (!dateFormId) throw new Error("Date de formation requise pour un apprenant");
result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
dateFormationId: dateFormId,
periode: input.periode,
modeValidation: input.modeValidation,
modeValidation: input.modeValidation || "qrcode",
validateurId: input.validateurId,
commentaire: input.commentaire,
signatureUrl,