Checkpoint: Système d'émargement numérique avec QR code et génération automatique d'attestations :

## Base de données
-  Table `presences` créée (inscriptionId, dateFormationId, heurePresence, modeValidation, validateurId)
-  Colonne `qrCodeToken` ajoutée à la table `sequences`

## Backend
-  Génération de QR code unique par séquence (qrCodeGenerator.ts)
-  Procédures tRPC pour l'émargement (presences.generateQRCode, presences.valider, presences.listBySequence)
-  Fonctions de base de données (presenceDb.ts) pour gérer les présences
-  Génération automatique d'attestation après validation de toutes les présences
-  Intégration avec le service d'attestations existant

## Interface formateur (/formateur/emargement)
-  Sélection de la séquence
-  Génération et affichage du QR code en modal
-  Liste des inscrits avec statut de présence par date
-  Validation manuelle de présence possible
-  Actualisation en temps réel des présences
-  Entrée de menu "Émargement" dans DashboardLayout

## Interface publique (/emargement/:token)
-  Page de scan QR code accessible sans authentification
-  Recherche de l'apprenant par email
-  Sélection de la formation et de la date
-  Validation automatique de la présence via QR code
-  Écrans de confirmation (succès/erreur)
-  Gestion des erreurs (QR invalide, déjà émargé, etc.)

## Génération automatique d'attestations
-  Vérification automatique après chaque validation de présence
-  Génération du PDF d'attestation si toutes les présences sont validées
-  Enregistrement en base de données
-  Upload automatique sur S3

## Dépendances installées
- qrcode (génération de QR codes)
- @types/qrcode (types TypeScript)

Le système est entièrement fonctionnel et prêt à être utilisé par les formateurs et apprenants.
This commit is contained in:
Manus
2026-01-11 14:01:22 -05:00
parent f9aa8f7838
commit b64dedc488
16 changed files with 3566 additions and 0 deletions

View File

@@ -435,6 +435,14 @@ export const appRouter = router({
// ===== INSCRIPTIONS =====
inscriptions: router({
// Liste des inscriptions avec dates de formation (pour formateurs)
listWithDates: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const { getInscriptionsWithDates } = await import("./inscriptionWithDates");
return getInscriptionsWithDates(input.sequenceId);
}),
listAll: adminProcedure.query(async () => {
return db.getAllInscriptions();
}),
@@ -447,6 +455,13 @@ export const appRouter = router({
return db.getInscriptionsByApprenant(input.apprenantId);
}),
listByApprenantWithDates: publicProcedure
.input(z.object({ apprenantId: z.number() }))
.query(async ({ input }) => {
const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates");
return getInscriptionsByApprenantWithDates(input.apprenantId);
}),
checkExisting: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
@@ -2029,6 +2044,161 @@ export const appRouter = router({
return { success: true };
}),
}),
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
presences: router({
// Générer un QR code pour une séquence
generateQRCode: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken, generateQRCodeDataURL } = 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
const qrCodeDataURL = await generateQRCodeDataURL(token);
return {
token,
qrCodeDataURL,
};
}),
// Valider une présence (scan QR code ou manuel)
valider: publicProcedure
.input(z.object({
token: z.string().optional(),
inscriptionId: z.number(),
dateFormationId: z.number(),
modeValidation: z.enum(["qrcode", "manuel"]),
validateurId: z.number().optional(),
commentaire: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { validerPresence } = await import("./presenceDb");
const { getDb } = await import("./db");
const { sequences, inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
// Si mode QR code, vérifier le token
if (input.modeValidation === "qrcode" && input.token) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// 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) {
throw new Error("QR code invalide");
}
}
const result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
modeValidation: input.modeValidation,
validateurId: input.validateurId,
commentaire: input.commentaire,
});
// 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 } = await import("./attestationService");
const { createAttestation } = await import("./attestationService");
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Enregistrer l'attestation en base
await createAttestation({
inscriptionId: input.inscriptionId,
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;
}),
// Lister les présences pour une séquence
listBySequence: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getPresencesBySequence } = await import("./presenceDb");
return getPresencesBySequence(input.sequenceId);
}),
// Lister les présences pour une inscription
listByInscription: publicProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.query(async ({ input }) => {
const { getPresencesByInscription } = await import("./presenceDb");
return getPresencesByInscription(input.inscriptionId);
}),
// Supprimer une présence
delete: protectedProcedure
.input(z.object({
presenceId: z.number(),
}))
.mutation(async ({ input }) => {
const { supprimerPresence } = await import("./presenceDb");
return supprimerPresence(input.presenceId);
}),
// Vérifier si toutes les présences sont validées pour une inscription
checkAllValidated: publicProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.query(async ({ input }) => {
const { checkAllPresencesValidated } = await import("./presenceDb");
return checkAllPresencesValidated(input.inscriptionId);
}),
}),
});
export type AppRouter = typeof appRouter;