diff --git a/drizzle/schema.ts b/drizzle/schema.ts index e32a8c0..e4f797e 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -38,8 +38,8 @@ export type InsertUser = typeof users.$inferInsert; */ export const passwordResetTokens = mysqlTable("passwordResetTokens", { id: int("id").autoincrement().primaryKey(), - /** Email de l'utilisateur qui demande la réinitialisation */ - email: varchar("email", { length: 320 }).notNull(), + /** Utilisateur propriétaire du token. Ce contrat correspond à la table déployée. */ + userId: int("userId").notNull(), /** Token unique généré pour la réinitialisation */ token: varchar("token", { length: 255 }).notNull().unique(), /** Date d'expiration du token (24h après création) */ @@ -143,7 +143,8 @@ export const inscriptions = mysqlTable("inscriptions", { id: int("id").autoincrement().primaryKey(), sequenceId: int("sequenceId").notNull(), apprenantId: int("apprenantId").notNull(), - statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(), + /** `liste_attente` est la valeur historique et réellement déployée dans les trois environnements. */ + statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(), dateInscription: timestamp("dateInscription").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); @@ -190,10 +191,31 @@ export type InsertEmailConfig = typeof emailConfig.$inferInsert; /** * Table des templates d'emails */ +export const emailTemplateTypes = [ + "inscription", + "teaser", + "rappel", + "rappelJ1", + "rappel1", + "rappel2", + "rappel3", + "rappel4", + "rappel5", + "rappel6", + "reset_password", + "attestation", +] as const; + +export type EmailTemplateType = (typeof emailTemplateTypes)[number]; + +export function isEmailTemplateType(value: string): value is EmailTemplateType { + return (emailTemplateTypes as readonly string[]).includes(value); +} + export const emailTemplates = mysqlTable("emailTemplates", { id: int("id").autoincrement().primaryKey(), /** Type de template (inscription, teaser, rappel1, rappel2, rappel3, rappel4, rappel5, rappel6, reset_password) */ - type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "attestation"]).notNull().unique(), + type: mysqlEnum("type", emailTemplateTypes).notNull().unique(), titre: varchar("titre", { length: 255 }).notNull(), /** Corps du message avec variables dynamiques */ bodyContent: text("bodyContent").notNull(), @@ -312,7 +334,13 @@ export const questionnaires = mysqlTable("questionnaires", { description: text("description"), /** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */ type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(), + /** Formation ciblée ; null signifie que le questionnaire s'applique à toutes les formations. */ + formationId: int("formationId"), actif: boolean("actif").default(true).notNull(), + /** Active l'envoi automatique après la fin d'une séquence. */ + envoiAutomatique: boolean("envoiAutomatique").default(false).notNull(), + /** Délai, en jours, entre la fin de séquence et l'envoi automatique. */ + delaiEnvoiJours: int("delaiEnvoiJours").default(1).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); @@ -335,9 +363,14 @@ export const questions = mysqlTable("questions", { valeurMin: int("valeurMin"), /** Valeur maximale pour les échelles */ valeurMax: int("valeurMax"), + /** Libellé affiché pour l'extrémité minimale de l'échelle. */ + echelleLabelMin: varchar("echelleLabelMin", { length: 100 }), + /** Libellé affiché pour l'extrémité maximale de l'échelle. */ + echelleLabelMax: varchar("echelleLabelMax", { length: 100 }), obligatoire: boolean("obligatoire").default(false).notNull(), ordre: int("ordre").notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type Question = typeof questions.$inferSelect; diff --git a/server/__tests__/passwordResetEmailTemplate.test.ts b/server/__tests__/passwordResetEmailTemplate.test.ts new file mode 100644 index 0000000..fefd54c --- /dev/null +++ b/server/__tests__/passwordResetEmailTemplate.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { replaceEmailVariables } from "../emailTemplateUtils"; + +const getEmailTemplateByType = vi.fn(); + +vi.mock("../db", () => ({ getEmailTemplateByType })); + +const databaseHelpers = readFileSync(new URL("../db.ts", import.meta.url), "utf8"); +const emailService = readFileSync(new URL("../emailService.ts", import.meta.url), "utf8"); + +describe("modèle d’e-mail de réinitialisation", () => { + it("conserve un lien unique sous forme de variable dans le modèle par défaut", () => { + expect(databaseHelpers).toContain('type: "reset_password"'); + expect(databaseHelpers).toContain("{{lienReinitialisation}}"); + expect(emailService).toContain("lienReinitialisation: params.resetLink"); + }); + + it("substitue le lien de réinitialisation dans le contenu du modèle", () => { + const link = "https://formations.recette.santinova-soft.org/reset-password?token=abc123"; + const result = replaceEmailVariables( + 'Réinitialiser', + { lienReinitialisation: link }, + ); + + expect(result).toContain(`href="${link}"`); + expect(result).not.toContain("{{lienReinitialisation}}"); + }); + + it("ajoute le contenu de secours lorsque le modèle existant ne contient pas de lien", async () => { + getEmailTemplateByType.mockResolvedValue({ + type: "reset_password", + titre: "Réinitialisation", + bodyContent: "
Réinitialisation mot de passe
", + couleurPrincipale: "#283581", + couleurSecondaire: "#0578BE", + piedDePage: null, + }); + + const { generateEmailFromTemplate } = await import("../emailTemplateGenerator"); + const resetLink = "https://formations.recette.santinova-soft.org/reset-password?token=abc123"; + const html = await generateEmailFromTemplate( + "reset_password", + `Réinitialiser`, + { lienReinitialisation: resetLink }, + ); + + expect(html).toContain("Réinitialisation mot de passe"); + expect(html).toContain(`href="${resetLink}"`); + }); +}); diff --git a/server/__tests__/passwordResetTokenContract.test.ts b/server/__tests__/passwordResetTokenContract.test.ts new file mode 100644 index 0000000..c8b2113 --- /dev/null +++ b/server/__tests__/passwordResetTokenContract.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { getDb, createPasswordResetToken, getPasswordResetToken, markTokenAsUsed } from "../db"; +import { passwordResetTokens, users } from "../../drizzle/schema"; + +const schema = readFileSync( + new URL("../../drizzle/schema.ts", import.meta.url), + "utf8", +); +const databaseHelpers = readFileSync( + new URL("../db.ts", import.meta.url), + "utf8", +); +const router = readFileSync( + new URL("../routers.ts", import.meta.url), + "utf8", +); +const passwordResetTokenSchema = schema.match( + /export const passwordResetTokens = mysqlTable\([\s\S]*?\n\}\);/, +)?.[0]; + +describe("contrat des tokens de réinitialisation", () => { + it("référence un utilisateur et non une colonne email absente de la table déployée", () => { + expect(passwordResetTokenSchema).toContain('userId: int("userId").notNull()'); + expect(passwordResetTokenSchema).not.toContain('email: varchar("email", { length: 320 }).notNull()'); + expect(databaseHelpers).toContain( + "createPasswordResetToken(userId: number, token: string)", + ); + expect(databaseHelpers).toContain("userId,"); + }); + + it("utilise l’identifiant utilisateur dans les parcours public et administrateur", () => { + expect(router).toContain("db.createPasswordResetToken(utilisateur.id, token)"); + expect(router).toContain("db.createPasswordResetToken(user.id, token)"); + expect(router).toContain("db.getUserById(resetToken.userId)"); + }); + + it("ne génère plus de lien de réinitialisation sur un domaine fictif", () => { + expect(router).not.toContain("https://votre-domaine.com/reset-password"); + }); + + it("insère et consomme un token lié à un utilisateur sans dépendre d’un e-mail", async () => { + const db = await getDb(); + if (!db) throw new Error("Base de test indisponible"); + + const [user] = await db.select({ id: users.id }).from(users).limit(1); + if (!user) throw new Error("Utilisateur de test introuvable"); + + const token = `test-${randomUUID()}`; + try { + await createPasswordResetToken(user.id, token); + + const created = await getPasswordResetToken(token); + expect(created?.userId).toBe(user.id); + expect(created?.used).toBe(false); + + await markTokenAsUsed(created!.id); + const consumed = await getPasswordResetToken(token); + expect(consumed?.used).toBe(true); + } finally { + await db.delete(passwordResetTokens).where(eq(passwordResetTokens.token, token)); + } + }); +}); diff --git a/server/db.ts b/server/db.ts index cfbe2cc..edf8800 100644 --- a/server/db.ts +++ b/server/db.ts @@ -23,6 +23,8 @@ import { Apprenant, Formation, EmailTemplate, + EmailTemplateType, + isEmailTemplateType, InsertEmailTemplate, formateurs, InsertFormateur, @@ -451,7 +453,14 @@ export async function updateApprenant(id: number, data: PartialContenu par défaut
", + // Le lien doit rester une variable : chaque demande génère un token différent. + bodyContent: `Bonjour {{prenomApprenant}} {{nomApprenant}},
+Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.
+Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :
+Réinitialiser mon mot de passe
+Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
+{{lienReinitialisation}}
+Ce lien est valide pendant 24 heures et ne peut être utilisé qu’une seule fois.
`, couleurPrincipale: "#283581", couleurSecondaire: "#0578BE", piedDePage: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", diff --git a/server/emailService.ts b/server/emailService.ts index b311a5e..25691dd 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -8,6 +8,8 @@ import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_c import { generateEmailFromTemplate } from "./emailTemplateGenerator"; import { EmailVariables } from "./emailTemplateUtils"; +type GroupEmailType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6"; + interface EmailParams { to: string; subject: string; @@ -612,10 +614,10 @@ export async function sendRappelJ1Email(params: { * Envoie des emails groupés à tous les inscrits d'une séquence */ export async function sendGroupEmail(params: { - recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>; + recipients: Array<{ email: string; prenom: string; nom: string; fonction: string | null }>; formationNom: string; sequenceNom: string; - type: 'teaser' | 'rappel' | 'rappel_j1'; + type: GroupEmailType; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; lieu?: string; formateur?: string; @@ -630,37 +632,26 @@ export async function sendGroupEmail(params: { apprenantEmail: recipient.email, apprenantPrenom: recipient.prenom, apprenantNom: recipient.nom, - apprenantFonction: recipient.fonction, + apprenantFonction: recipient.fonction || '', formationNom: params.formationNom, sequenceNom: params.sequenceNom, dates: params.dates, lieu: params.lieu, formateur: params.formateur, }); - } else if (params.type === 'rappel') { - await sendRappelJ7Email({ + } else { + // Un seul chemin pour les six rappels : le contenu est celui du template choisi. + await sendRappelEmail({ apprenantEmail: recipient.email, apprenantPrenom: recipient.prenom, apprenantNom: recipient.nom, - apprenantFonction: recipient.fonction, + apprenantFonction: recipient.fonction || '', formationNom: params.formationNom, sequenceNom: params.sequenceNom, dates: params.dates, lieu: params.lieu || '', formateur: params.formateur, - }); - } else { - // rappel_j1 - await sendRappelJ1Email({ - apprenantEmail: recipient.email, - apprenantPrenom: recipient.prenom, - apprenantNom: recipient.nom, - apprenantFonction: recipient.fonction, - formationNom: params.formationNom, - sequenceNom: params.sequenceNom, - dates: params.dates, - lieu: params.lieu, - formateur: params.formateur, + templateType: params.type, }); } sent++; @@ -714,7 +705,13 @@ export async function sendPasswordResetEmail(params: { return sendEmail({ to: params.apprenantEmail, subject: 'Réinitialisation de votre mot de passe - Manager Itinova', - html: await getEmailTemplate(content, 'reset_password'), + // Le modèle reset_password doit recevoir le lien : son bodyContent est personnalisable + // et ne doit jamais masquer l'action de réinitialisation. + html: await getEmailTemplate(content, 'reset_password', { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + lienReinitialisation: params.resetLink, + }), }); } diff --git a/server/emailTemplateGenerator.ts b/server/emailTemplateGenerator.ts index f6d4c35..de3235a 100644 --- a/server/emailTemplateGenerator.ts +++ b/server/emailTemplateGenerator.ts @@ -36,6 +36,13 @@ export async function generateEmailFromTemplate( function buildEmailHTML(template: EmailTemplate, content: string, variables?: EmailVariables): string { // Utiliser bodyContent si disponible, sinon utiliser content (legacy) let emailBody = template.bodyContent || content || ''; + + // Les anciens modèles reset_password enregistrés avant l’ajout de la variable + // ne contiennent que du texte. On conserve ce texte, puis on ajoute le contenu + // métier (bouton et lien unique) afin que le destinataire puisse agir. + if (template.type === 'reset_password' && !emailBody.includes('{{lienReinitialisation}}')) { + emailBody = `${emailBody}\n${content}`; + } // Remplacer les variables si fournies if (variables && emailBody) { diff --git a/server/emailTemplateUtils.ts b/server/emailTemplateUtils.ts index b5ad696..73ea1f9 100644 --- a/server/emailTemplateUtils.ts +++ b/server/emailTemplateUtils.ts @@ -13,6 +13,7 @@ export interface EmailVariables { lieu?: string; formateur?: string; lienInscription?: string; + lienReinitialisation?: string; [key: string]: string | undefined; } @@ -65,6 +66,7 @@ export function generateEmailPreview(template: string): string { lieu: 'Salle de formation A - Bâtiment principal', formateur: 'Jean Martin', lienInscription: 'https://exemple.com/inscription/abc123', + lienReinitialisation: 'https://exemple.com/reset-password?token=abc123', }; return replaceEmailVariables(template, exampleVariables); diff --git a/server/routers.ts b/server/routers.ts index 2f6eb09..3f926c0 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -19,6 +19,23 @@ import { sql } from "drizzle-orm"; import { catalogueRouter } from "./routers/catalogue"; import { planFormationRouter } from "./routers/planFormation"; +/** + * Construit le lien de réinitialisation avec le domaine réellement utilisé. + * Cela permet au même code de fonctionner en local, recette et production. + */ +function buildPasswordResetUrl(req: { protocol: string; get: (name: string) => string | undefined }, token: string) { + const forwardedProtocol = req.get("x-forwarded-proto")?.split(",")[0]?.trim(); + const protocol = forwardedProtocol || req.protocol; + const forwardedHost = req.get("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || req.get("host"); + + if (!host) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "URL de réinitialisation indisponible" }); + } + + return `${protocol}://${host}/reset-password?token=${encodeURIComponent(token)}`; +} + // Procédure admin uniquement const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== 'admin') { @@ -52,7 +69,7 @@ export const appRouter = router({ .input(z.object({ email: z.string().email(), })) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const { email } = input; // Vérifier si l'utilisateur existe @@ -66,10 +83,10 @@ export const appRouter = router({ const token = crypto.randomBytes(32).toString('hex'); // Sauvegarder le token dans la base de données - await db.createPasswordResetToken(email, token); + await db.createPasswordResetToken(utilisateur.id, token); // Envoyer l'email avec le template reset_password - const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`; + const resetUrl = buildPasswordResetUrl(ctx.req, token); await sendResetPasswordEmail({ email, @@ -117,7 +134,7 @@ export const appRouter = router({ } // Récupérer l'utilisateur - const utilisateur = await db.getUtilisateurByEmail(resetToken.email); + const utilisateur = await db.getUserById(resetToken.userId); if (!utilisateur) { throw new TRPCError({ code: 'NOT_FOUND', @@ -667,7 +684,8 @@ export const appRouter = router({ // Vérifier si la séquence est bloquée const now = new Date(); - if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { + // Une date de blocage est optionnelle : ne jamais transformer `null` en 01/01/1970. + if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)' @@ -685,7 +703,7 @@ export const appRouter = router({ // Vérifier la capacité const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); - const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee'; + const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee'; await db.createInscription({ apprenantId: input.apprenantId, @@ -785,7 +803,7 @@ export const appRouter = router({ const admins = await db.getAdminUsers(); const adminEmails = admins.filter(a => a.email).map(a => a.email!); if (adminEmails.length > 0) { - const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente'); + const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente'); let formateurNom: string | undefined; if (inscriptionSequence.formateurId) { const formateur = await db.getFormateurById(inscriptionSequence.formateurId); @@ -850,7 +868,7 @@ export const appRouter = router({ // Vérifier si la séquence est bloquée const now = new Date(); - if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { + if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)' @@ -927,7 +945,7 @@ export const appRouter = router({ // Notifier le premier en liste d'attente qu'une place s'est libérée const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId); const premierEnAttente = inscriptionsListeAttente - .filter(i => i.inscription.statut === 'en_attente') + .filter(i => i.inscription.statut === 'liste_attente') .sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0]; if (premierEnAttente && premierEnAttente.apprenant) { @@ -973,7 +991,7 @@ export const appRouter = router({ updateStatut: adminProcedure.input(z.object({ id: z.number(), - statut: z.enum(['confirmee', 'en_attente', 'annulee']), + statut: z.enum(['confirmee', 'liste_attente', 'annulee']), })).mutation(async ({ input }) => { // Récupérer l'inscription avant modification const inscriptionAvant = await db.getInscriptionById(input.id); @@ -1293,7 +1311,7 @@ export const appRouter = router({ await db.createInscription({ apprenantId: input.apprenantId, sequenceId: input.sequenceId, - statut: input.statut === "confirme" ? "confirmee" : "en_attente", + statut: input.statut === "confirme" ? "confirmee" : "liste_attente", }); return { success: true }; @@ -1384,7 +1402,7 @@ export const appRouter = router({ return { success: true }; }), - requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input, ctx }) => { const user = await db.getUserById(input.id); if (!user) { throw new Error("Utilisateur introuvable"); @@ -1399,11 +1417,10 @@ export const appRouter = router({ const token = crypto.randomBytes(32).toString('hex'); // Sauvegarder le token en base (la fonction gère l'expiration automatiquement) - await db.createPasswordResetToken(user.email, token); + await db.createPasswordResetToken(user.id, token); - // Générer le lien de réinitialisation - // TODO: Remplacer par l'URL réelle de votre application en production - const resetLink = `https://votre-domaine.com/reset-password?token=${token}`; + // Générer le lien de réinitialisation sur l’environnement réellement appelé. + const resetLink = buildPasswordResetUrl(ctx.req, token); // Envoyer l'email const emailService = await import('./emailService'); @@ -2057,8 +2074,8 @@ export const appRouter = router({ 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(), + valeurMin: z.number().optional(), + valeurMax: z.number().optional(), echelleLabelMin: z.string().optional(), echelleLabelMax: z.string().optional(), obligatoire: z.boolean().default(false), @@ -2076,8 +2093,8 @@ export const appRouter = router({ 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(), + valeurMin: z.number().optional(), + valeurMax: z.number().optional(), echelleLabelMin: z.string().optional(), echelleLabelMax: z.string().optional(), obligatoire: z.boolean().optional(),