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:
85
server/inscriptionWithDates.ts
Normal file
85
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;
|
||||
}
|
||||
149
server/presenceDb.ts
Normal file
149
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 };
|
||||
}
|
||||
60
server/qrCodeGenerator.ts
Normal file
60
server/qrCodeGenerator.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import QRCode from "qrcode";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* 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 url = `${process.env.VITE_APP_URL || "http://localhost:3000"}/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 url = `${process.env.VITE_APP_URL || "http://localhost:3000"}/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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user