Checkpoint: Système de questionnaires de satisfaction et d'évaluation - Phase 1 & 2 complétées

 Schéma de base de données créé :
- Table questionnaires (satisfaction, evaluation_pre, evaluation_post)
- Table questions (choix_multiple, echelle, texte_libre, oui_non)
- Table reponsesQuestionnaires
- Table reponsesQuestions
- Table envoisQuestionnaires

 Backend complet implémenté :
- Fichier questionnaireDb.ts avec toutes les fonctions CRUD
- Procédures tRPC pour questionnaires (list, getById, create, update, delete, getStats, getReponsesDetaillees)
- Procédures tRPC pour questions (list, create, update, delete)
- Procédures tRPC publiques pour réponses (getByToken, submit)
- Fonctions de statistiques (taux de réponse, réponses détaillées)

 Corrections :
- Erreurs TypeScript dans exportService.ts corrigées

🚧 À faire dans la prochaine session :
- Interfaces d'administration (AdminQuestionnaires, AdminQuestionnaireEdit)
- Page publique de réponse aux questionnaires
- Système d'envoi automatique post-formation
- Tableaux de bord d'analyse avec graphiques
- Exports Excel/PDF des feedbacks
This commit is contained in:
Manus Sandbox
2025-12-05 06:55:06 -05:00
parent 228c0e0056
commit 3521c55297
11 changed files with 2164 additions and 979 deletions

View File

@@ -206,7 +206,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
// Date de la journée
doc.setFontSize(12);
doc.setFont(undefined, 'bold');
doc.setFont('helvetica', 'bold');
doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
@@ -215,7 +215,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
doc.setFont(undefined, 'normal');
doc.setFont('helvetica', 'normal');
yPos += 10;
// Tableau de présence avec signatures matin et après-midi
@@ -256,9 +256,9 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
const signatureY = finalY + 20;
doc.setFontSize(10);
doc.setFont(undefined, 'bold');
doc.setFont('helvetica', 'bold');
doc.text('Signature du formateur :', 14, signatureY);
doc.setFont(undefined, 'normal');
doc.setFont('helvetica', 'normal');
// Dessiner une ligne pour la signature
doc.line(60, signatureY, 120, signatureY);

250
server/questionnaireDb.ts Normal file
View 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;
}

View File

@@ -884,6 +884,13 @@ export const appRouter = router({
await db.updateRappel(input.id, { actif: input.actif });
return { success: true };
}),
envoyerAutomatiques: publicProcedure
.mutation(async () => {
const { processRappelsAutomatiques } = await import('./rappelScheduler');
const result = await processRappelsAutomatiques();
return result;
}),
}),
emailConfig: router({
@@ -997,6 +1004,197 @@ export const appRouter = router({
}),
}),
// ===== QUESTIONNAIRES =====
questionnaires: router({
list: adminProcedure.query(async () => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getAllQuestionnaires();
}),
getById: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionnaireById(input.id);
}),
create: adminProcedure
.input(z.object({
nom: z.string(),
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]),
description: z.string().optional(),
formationId: z.number().nullable().optional(),
actif: z.boolean().default(true),
envoiAutomatique: z.boolean().default(false),
delaiEnvoiJours: z.number().default(1),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const id = await questionnaireDb.createQuestionnaire(input);
return { id, success: true };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
nom: z.string().optional(),
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(),
description: z.string().optional(),
formationId: z.number().nullable().optional(),
actif: z.boolean().optional(),
envoiAutomatique: z.boolean().optional(),
delaiEnvoiJours: z.number().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestionnaire(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestionnaire(input.id);
return { success: true };
}),
getStats: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionnaireStats(input.id);
}),
getReponsesDetaillees: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
}),
}),
// ===== QUESTIONS =====
questions: router({
list: adminProcedure
.input(z.object({ questionnaireId: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId);
}),
create: adminProcedure
.input(z.object({
questionnaireId: z.number(),
ordre: z.number(),
texte: z.string(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const id = await questionnaireDb.createQuestion(input);
return { id, success: true };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
ordre: z.number().optional(),
texte: z.string().optional(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestion(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestion(input.id);
return { success: true };
}),
}),
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
reponses: router({
getByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) return null;
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
return {
envoi,
questionnaire,
questions,
};
}),
submit: publicProcedure
.input(z.object({
token: z.string(),
reponses: z.array(z.object({
questionId: z.number(),
reponseNumerique: z.number().optional(),
reponseTexte: z.string().optional(),
})),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
// Récupérer l'envoi
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) throw new Error("Token invalide");
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
// Créer la réponse au questionnaire
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
questionnaireId: envoi.questionnaireId,
apprenantId: envoi.apprenantId,
sequenceId: envoi.sequenceId,
complete: true,
});
// Enregistrer chaque réponse
for (const reponse of input.reponses) {
await questionnaireDb.createReponseQuestion({
reponseQuestionnaireId,
questionId: reponse.questionId,
reponseNumerique: reponse.reponseNumerique,
reponseTexte: reponse.reponseTexte,
});
}
// Mettre à jour l'envoi
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
dateReponse: new Date(),
});
return { success: true };
}),
}),
// ===== ÉTABLISSEMENTS =====
etablissements: router({
list: adminProcedure.query(async () => {