Compare commits

..

1 Commits

Author SHA1 Message Date
manus-admin
ba9052d87f ci: trigger 2026-08-21 13:21:06 +02:00
38 changed files with 266 additions and 808 deletions

View File

@@ -1,12 +0,0 @@
node_modules
dist
.git
.env
.env.*
uploads
mysql-data
src
deployment-package
*.tar.gz
*.pdf
*.docx

View File

@@ -22,7 +22,7 @@ nano .env
### 3. Ajouter la variable APP_PUBLIC_URL ### 3. Ajouter la variable APP_PUBLIC_URL
Ajoutez cette ligne dans le fichier `.env` : Ajoutez cette ligne dans le fichier `.env` :
``` ```
APP_PUBLIC_URL=https://formations-itinova.santinova-soft.org APP_PUBLIC_URL=https://formations.itinova.org
``` ```
### 4. Redémarrer l'application ### 4. Redémarrer l'application
@@ -38,7 +38,7 @@ sudo systemctl restart formation-manager-itinova
1. Connectez-vous à l'interface formateur 1. Connectez-vous à l'interface formateur
2. Générez un nouveau QR code pour une séquence 2. Générez un nouveau QR code pour une séquence
3. Scannez le QR code avec votre téléphone 3. Scannez le QR code avec votre téléphone
4. Vérifiez que l'URL commence par `https://formations-itinova.santinova-soft.org/emargement/...` 4. Vérifiez que l'URL commence par `https://formations.itinova.org/emargement/...`
## Alternative : Utiliser DOMAIN_NAME ## Alternative : Utiliser DOMAIN_NAME
Si vous préférez, vous pouvez aussi utiliser : Si vous préférez, vous pouvez aussi utiliser :

View File

@@ -1,45 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Corepack applique la version de pnpm verrouillée dans package.json.
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
# Les variables Vite doivent être disponibles pendant la compilation frontend.
ARG VITE_APP_ID
ARG VITE_OAUTH_PORTAL_URL
ARG VITE_APP_TITLE
ARG VITE_FRONTEND_FORGE_API_URL
ARG VITE_FRONTEND_FORGE_API_KEY
ARG VITE_ANALYTICS_ENDPOINT
ARG VITE_ANALYTICS_WEBSITE_ID
ARG VITE_APP_LOGO
ENV VITE_APP_ID=${VITE_APP_ID} \
VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL} \
VITE_APP_TITLE=${VITE_APP_TITLE} \
VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL} \
VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY} \
VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT} \
VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID} \
VITE_APP_LOGO=${VITE_APP_LOGO}
RUN pnpm run build
# L’image d’exécution n’embarque ni TypeScript, ni Vite, ni les outils de test.
RUN pnpm prune --prod
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]

View File

@@ -170,12 +170,12 @@ Si tout se passe bien, vous verrez un message de succès indiquant que le certif
Testez que votre site est accessible en HTTPS : Testez que votre site est accessible en HTTPS :
```bash ```bash
curl -I https://formations-itinova.santinova-soft.org curl -I https://formations.itinova.org
``` ```
Vous devriez voir une réponse HTTP/2 200 OK. Vous devriez voir une réponse HTTP/2 200 OK.
Ouvrez votre navigateur et accédez à `https://formations-itinova.santinova-soft.org`. Vous devriez voir : Ouvrez votre navigateur et accédez à `https://formations.itinova.org`. Vous devriez voir :
- Un cadenas vert dans la barre d'adresse - Un cadenas vert dans la barre d'adresse
- Votre application fonctionnant normalement - Votre application fonctionnant normalement
- Aucun avertissement de sécurité - Aucun avertissement de sécurité

View File

@@ -3,8 +3,8 @@
"name": "Formation Manager", "name": "Formation Manager",
"category": "ITINOVA", "category": "ITINOVA",
"urls": { "urls": {
"recette": "https://formations-itinova.recette.santinova-soft.org", "recette": "https://formations.recette.santinova-soft.org",
"prod": "https://formations-itinova.santinova-soft.org" "prod": "https://formations.santinova-soft.org"
}, },
"containerName": "formation-manager-itinova", "containerName": "formation-manager-itinova",
"image": "images/formation-manager.png", "image": "images/formation-manager.png",

View File

@@ -6,9 +6,9 @@
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" /> content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/itinova-logo.png" /> <link rel="icon" type="image/png" href="%VITE_APP_LOGO%" />
<link rel="apple-touch-icon" href="/itinova-logo.png" /> <link rel="apple-touch-icon" href="%VITE_APP_LOGO%" />
<title>Gestion des Formations Manager Itinova</title> <title>%VITE_APP_TITLE%</title>
<!-- Add Google Fonts here, example: <!-- Add Google Fonts here, example:
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -5,6 +5,7 @@ import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home"; import Home from "./pages/Home";
import LoginChoice from "./pages/LoginChoice";
import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import AdminRapportPublicCible from "./pages/AdminRapportPublicCible";
import Admin from "./pages/Admin"; import Admin from "./pages/Admin";
import AdminFormations from "./pages/AdminFormations"; import AdminFormations from "./pages/AdminFormations";
@@ -50,7 +51,7 @@ import CatalogueFormation from "./pages/CatalogueFormation";
function Router() { function Router() {
return ( return (
<Switch> <Switch>
<Route path={"/"} component={Login} /> <Route path={"/"} component={LoginChoice} />
<Route path={"/home"} component={Home} /> <Route path={"/home"} component={Home} />
<Route path={"/login"} component={Login} /> <Route path={"/login"} component={Login} />
<Route path={"/forgot-password"} component={ForgotPassword} /> <Route path={"/forgot-password"} component={ForgotPassword} />

View File

@@ -1,7 +1,6 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Les déploiements autonomes ne reçoivent pas systématiquement les variables Vite de la plateforme. export const APP_TITLE = import.meta.env.VITE_APP_TITLE || "App";
export const APP_TITLE = import.meta.env.VITE_APP_TITLE || "Gestion des Formations Manager Itinova";
export const APP_LOGO = "/itinova-logo.png"; export const APP_LOGO = "/itinova-logo.png";

View File

@@ -69,7 +69,7 @@ export default function AdminApprenantDetail() {
case 'chef_service': case 'chef_service':
return 'Chefs de service'; return 'Chefs de service';
case 'tous': case 'tous':
return 'Tous managers'; return 'Tous';
default: default:
return 'Autre'; return 'Autre';
} }
@@ -120,7 +120,7 @@ export default function AdminApprenantDetail() {
</div> </div>
<div> <div>
<div className="text-sm font-medium text-muted-foreground">Fonction</div> <div className="text-sm font-medium text-muted-foreground">Fonction</div>
<div className="text-lg">{getFonctionLabel(apprenant.fonction ?? "")}</div> <div className="text-lg">{getFonctionLabel(apprenant.fonction)}</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye, Download } from "lucide-react"; import { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye } from "lucide-react";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -67,27 +67,6 @@ export default function AdminApprenants() {
}, },
}); });
const exportExcelMutation = trpc.apprenants.exportExcel.useMutation({
onSuccess: (result) => {
const blob = new Blob(
[Uint8Array.from(atob(result.buffer), (character) => character.charCodeAt(0))],
{ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("Export Excel des apprenants téléchargé");
},
onError: (error) => {
toast.error("Erreur lors de l'export : " + error.message);
},
});
const resetForm = () => { const resetForm = () => {
setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" }); setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" });
setEditingId(null); setEditingId(null);
@@ -120,10 +99,6 @@ export default function AdminApprenants() {
} }
}; };
const handleExportExcel = () => {
exportExcelMutation.mutate();
};
// Filtrer les apprenants // Filtrer les apprenants
const filteredApprenants = useMemo(() => { const filteredApprenants = useMemo(() => {
if (!apprenants) return []; if (!apprenants) return [];
@@ -133,7 +108,7 @@ export default function AdminApprenants() {
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(apprenant.codeEtablissement ?? "").toLowerCase().includes(searchTerm.toLowerCase()); apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction; const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction;
@@ -152,15 +127,6 @@ export default function AdminApprenants() {
</p> </p>
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={handleExportExcel}
disabled={exportExcelMutation.isPending}
>
<Download className="w-4 h-4 mr-2" />
{exportExcelMutation.isPending ? "Export en cours..." : "Exporter la liste Excel"}
</Button>
<Dialog open={open} onOpenChange={(isOpen) => { <Dialog open={open} onOpenChange={(isOpen) => {
setOpen(isOpen); setOpen(isOpen);
if (!isOpen) resetForm(); if (!isOpen) resetForm();
@@ -212,7 +178,6 @@ export default function AdminApprenants() {
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="jean.dupont@example.com" placeholder="jean.dupont@example.com"
autoComplete="off"
required required
/> />
</div> </div>
@@ -251,7 +216,6 @@ export default function AdminApprenants() {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
)} )}
</div> </div>

View File

@@ -154,7 +154,7 @@ export default function AdminCalendrier() {
const labels: Record<string, string> = { const labels: Record<string, string> = {
directeurs: "Directeurs", directeurs: "Directeurs",
chefs_service: "Chefs de service", chefs_service: "Chefs de service",
tous: "Tous managers", tous: "Tous",
autre: "Autre", autre: "Autre",
}; };
return labels[publicCible] || publicCible; return labels[publicCible] || publicCible;

View File

@@ -163,7 +163,7 @@ export default function AdminEmailTemplates() {
.replace(/\{\{formateur\}\}/g, 'Jean Martin') .replace(/\{\{formateur\}\}/g, 'Jean Martin')
.replace(/\{\{couleurPrincipale\}\}/g, formData.couleurPrincipale) .replace(/\{\{couleurPrincipale\}\}/g, formData.couleurPrincipale)
.replace(/\{\{couleurSecondaire\}\}/g, formData.couleurSecondaire) .replace(/\{\{couleurSecondaire\}\}/g, formData.couleurSecondaire)
.replace(/\{\{lienReinitialisation\}\}/g, 'https://formations-itinova.santinova-soft.org/reset-password/token123') .replace(/\{\{lienReinitialisation\}\}/g, 'https://formations.itinova.org/reset-password/token123')
.replace(/\{\{datesHTML\}\}/g, '<div class="date-item"><strong>Date 1 :</strong> lundi 15 janvier 2025</div><div class="date-item"><strong>Date 2 :</strong> mardi 16 janvier 2025</div>'); .replace(/\{\{datesHTML\}\}/g, '<div class="date-item"><strong>Date 1 :</strong> lundi 15 janvier 2025</div><div class="date-item"><strong>Date 2 :</strong> mardi 16 janvier 2025</div>');
}; };

View File

@@ -129,7 +129,7 @@ export default function AdminParametres() {
<Input <Input
id="urlPublique" id="urlPublique"
type="url" type="url"
placeholder="https://formations-itinova.santinova-soft.org" placeholder="https://formations.itinova.org"
value={urlPublique} value={urlPublique}
onChange={(e) => { onChange={(e) => {
setUrlPublique(e.target.value); setUrlPublique(e.target.value);
@@ -169,7 +169,7 @@ export default function AdminParametres() {
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Cette URL est utilisée pour générer les QR codes d'émargement. Cette URL est utilisée pour générer les QR codes d'émargement.
Exemple : https://formations-itinova.santinova-soft.org (sans slash à la fin) Exemple : https://formations.itinova.org (sans slash à la fin)
</p> </p>
</div> </div>

View File

@@ -68,7 +68,6 @@ export default function AdminRapportPublicCible() {
const labels = { const labels = {
directeur: "Directeur", directeur: "Directeur",
chef_service: "Chef de service", chef_service: "Chef de service",
tous: "Tous managers",
autre: "Autre", autre: "Autre",
}; };
return labels[publicCible as keyof typeof labels] || "Autre"; return labels[publicCible as keyof typeof labels] || "Autre";
@@ -78,7 +77,6 @@ export default function AdminRapportPublicCible() {
const colors = { const colors = {
directeur: "bg-blue-100 text-blue-800", directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800", chef_service: "bg-green-100 text-green-800",
tous: "bg-purple-100 text-purple-800",
autre: "bg-gray-100 text-gray-800", autre: "bg-gray-100 text-gray-800",
}; };
return colors[publicCible as keyof typeof colors] || colors.autre; return colors[publicCible as keyof typeof colors] || colors.autre;

View File

@@ -20,18 +20,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
type EmailActionType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6";
const EMAIL_ACTION_LABELS: Record<EmailActionType, string> = {
teaser: "teaser",
rappel1: "de rappel 1",
rappel2: "de rappel 2",
rappel3: "de rappel 3",
rappel4: "de rappel 4",
rappel5: "de rappel 5",
rappel6: "de rappel 6",
};
export default function AdminSequenceInscrits() { export default function AdminSequenceInscrits() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const [, params] = useRoute("/admin/sequences/:id/inscrits"); const [, params] = useRoute("/admin/sequences/:id/inscrits");
@@ -41,7 +29,6 @@ export default function AdminSequenceInscrits() {
const colors = { const colors = {
directeur: "bg-blue-100 text-blue-800", directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800", chef_service: "bg-green-100 text-green-800",
tous: "bg-purple-100 text-purple-800",
autre: "bg-gray-100 text-gray-800", autre: "bg-gray-100 text-gray-800",
}; };
return colors[publicCible as keyof typeof colors] || colors.autre; return colors[publicCible as keyof typeof colors] || colors.autre;
@@ -51,7 +38,6 @@ export default function AdminSequenceInscrits() {
const labels = { const labels = {
directeur: "Directeur", directeur: "Directeur",
chef_service: "Chef de service", chef_service: "Chef de service",
tous: "Tous managers",
autre: "Autre", autre: "Autre",
}; };
return labels[publicCible as keyof typeof labels] || "Autre"; return labels[publicCible as keyof typeof labels] || "Autre";
@@ -66,7 +52,7 @@ export default function AdminSequenceInscrits() {
const [isAddInscritOpen, setIsAddInscritOpen] = useState(false); const [isAddInscritOpen, setIsAddInscritOpen] = useState(false);
const [selectedApprenantId, setSelectedApprenantId] = useState<number | null>(null); const [selectedApprenantId, setSelectedApprenantId] = useState<number | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false); const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewType, setPreviewType] = useState<EmailActionType>("teaser"); const [previewType, setPreviewType] = useState<"teaser" | "rappel" | "rappel_j1">("teaser");
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId }); const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
@@ -182,8 +168,8 @@ export default function AdminSequenceInscrits() {
exportFeuilleMutation.mutate({ sequenceId }); exportFeuilleMutation.mutate({ sequenceId });
}; };
const handleSendEmails = (type: EmailActionType) => { const handleSendEmails = (type: "teaser" | "rappel" | "rappel_j1") => {
const typeLabel = EMAIL_ACTION_LABELS[type]; const typeLabel = type === "teaser" ? "teaser" : type === "rappel" ? "de rappel J-7" : "de rappel J-1";
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) { if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) {
sendEmailsMutation.mutate({ sequenceId, type }); sendEmailsMutation.mutate({ sequenceId, type });
} }
@@ -583,7 +569,7 @@ export default function AdminSequenceInscrits() {
<TableCell>{item.apprenant.prenom}</TableCell> <TableCell>{item.apprenant.prenom}</TableCell>
<TableCell>{item.apprenant.email}</TableCell> <TableCell>{item.apprenant.email}</TableCell>
<TableCell>{item.apprenant.codeEtablissement}</TableCell> <TableCell>{item.apprenant.codeEtablissement}</TableCell>
<TableCell>{getFonctionLabel(item.apprenant.fonction ?? "")}</TableCell> <TableCell>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
<TableCell className="whitespace-nowrap"> <TableCell className="whitespace-nowrap">
{formatDate(item.inscription.dateInscription)} {formatDate(item.inscription.dateInscription)}
</TableCell> </TableCell>
@@ -631,7 +617,7 @@ export default function AdminSequenceInscrits() {
sequenceId={sequenceId} sequenceId={sequenceId}
onSuccess={() => { onSuccess={() => {
setIsAddInscritOpen(false); setIsAddInscritOpen(false);
utils.inscriptions.listBySequence.invalidate({ sequenceId }); utils.inscriptions.getBySequence.invalidate({ sequenceId });
toast.success("Apprenant inscrit avec succès"); toast.success("Apprenant inscrit avec succès");
}} }}
onCancel={() => setIsAddInscritOpen(false)} onCancel={() => setIsAddInscritOpen(false)}
@@ -644,7 +630,7 @@ export default function AdminSequenceInscrits() {
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Aperçu de l'email {EMAIL_ACTION_LABELS[previewType]} Aperçu de l'email {previewType === "teaser" ? "teaser" : previewType === "rappel" ? "de rappel J-7" : "de rappel J-1"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{loadingPreview ? ( {loadingPreview ? (

View File

@@ -249,7 +249,7 @@ export default function AdminSequences() {
capaciteMax: parseInt(formData.capaciteMax), capaciteMax: parseInt(formData.capaciteMax),
dateBlocage: convertLocalToUTC(formData.dateBlocage), dateBlocage: convertLocalToUTC(formData.dateBlocage),
statut: formData.statut, statut: formData.statut,
formateurId: formData.formateur || null, formateurId: formData.formateur ? parseInt(formData.formateur) : null,
dates: formData.dates.map(d => ({ dates: formData.dates.map(d => ({
...d, ...d,
dateDebut: convertLocalToUTC(d.dateDebut), dateDebut: convertLocalToUTC(d.dateDebut),
@@ -429,7 +429,7 @@ export default function AdminSequences() {
const labels = { const labels = {
directeur: "Directeur", directeur: "Directeur",
chef_service: "Chef de service", chef_service: "Chef de service",
tous: "Tous managers", tous: "Tous",
autre: "Autre", autre: "Autre",
}; };
return labels[publicCible as keyof typeof labels] || "Autre"; return labels[publicCible as keyof typeof labels] || "Autre";
@@ -535,7 +535,7 @@ export default function AdminSequences() {
<SelectContent> <SelectContent>
<SelectItem value="directeur">Directeur</SelectItem> <SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem> <SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous managers</SelectItem> <SelectItem value="tous">Tous</SelectItem>
<SelectItem value="autre">Autre</SelectItem> <SelectItem value="autre">Autre</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1029,7 +1029,7 @@ export default function AdminSequences() {
<SelectContent> <SelectContent>
<SelectItem value="directeur">Directeur</SelectItem> <SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem> <SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="tous">Tous managers</SelectItem> <SelectItem value="tous">Tous</SelectItem>
<SelectItem value="autre">Autre</SelectItem> <SelectItem value="autre">Autre</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -12,7 +12,6 @@ import { fr } from "date-fns/locale";
import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react"; import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { APP_LOGO, APP_TITLE } from "@/const"; import { APP_LOGO, APP_TITLE } from "@/const";
import { sortSequencesForRegistration } from "@shared/registration";
export default function Inscription() { export default function Inscription() {
const [, params] = useRoute("/inscription/:lien"); const [, params] = useRoute("/inscription/:lien");
@@ -72,10 +71,9 @@ export default function Inscription() {
"autre": "autre" "autre": "autre"
}; };
const cibleFonction = publicCibleMap[selectedSequence.publicCible]; if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) {
if (cibleFonction && cibleFonction !== formData.fonction) {
const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" : const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" :
selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Tous managers"; selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Autre";
const fonctionLabel = formData.fonction === "directeur" ? "Directeur" : const fonctionLabel = formData.fonction === "directeur" ? "Directeur" :
formData.fonction === "chef_service" ? "Chef de service" : "Autre"; formData.fonction === "chef_service" ? "Chef de service" : "Autre";
@@ -118,12 +116,23 @@ export default function Inscription() {
} }
}; };
// Les sessions disponibles sont affichées en premier, puis les listes d'attente et les sessions fermées. // Calculer les places disponibles pour chaque sequence
const sessionsAvecPlaces = useMemo(() => { const sessionsAvecPlaces = useMemo(() => {
return sortSequencesForRegistration(sequences ?? []); if (!sequences) return [];
}, [sequences]);
const selectedSequence = sessionsAvecPlaces.find((sequence) => sequence.id === selectedSequenceId); return sequences.map(sequence => {
const now = new Date();
const dateBlocage = new Date(sequence.dateBlocage);
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits || 0));
return {
...sequence,
isBloquee,
placesRestantes,
};
});
}, [sequences]);
const formatDate = (dateValue: Date | string) => { const formatDate = (dateValue: Date | string) => {
if (!dateValue) return "N/A"; if (!dateValue) return "N/A";
@@ -250,9 +259,9 @@ export default function Inscription() {
{/* Sessions disponibles */} {/* Sessions disponibles */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>1. Choisissez votre session</CardTitle> <CardTitle>Sessions disponibles</CardTitle>
<CardDescription> <CardDescription>
Les sessions avec des places disponibles sont affichées en premier. Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum)
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -261,13 +270,14 @@ export default function Inscription() {
{sessionsAvecPlaces.map((sequence) => ( {sessionsAvecPlaces.map((sequence) => (
<div <div
key={sequence.id} key={sequence.id}
className={`border rounded-lg p-4 transition-all ${ className={`border rounded-lg p-4 cursor-pointer transition-all ${
selectedSequenceId === sequence.id selectedSequenceId === sequence.id
? "border-blue-600 bg-blue-50 ring-2 ring-blue-200 shadow-md" ? "border-blue-500 bg-blue-50"
: sequence.isBloquee : sequence.isBloquee
? "border-gray-300 bg-gray-50 opacity-60" ? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50" : "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
}`} }`}
onClick={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
@@ -289,7 +299,7 @@ export default function Inscription() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
<span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : sequence.publicCible === "tous" ? "Tous managers" : "Autre"}</span> <span>Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
@@ -316,24 +326,6 @@ export default function Inscription() {
)} )}
</div> </div>
</div> </div>
<Button
type="button"
className="mt-4 w-full"
variant={selectedSequenceId === sequence.id ? "secondary" : "default"}
disabled={sequence.isBloquee}
onClick={() => setSelectedSequenceId(sequence.id)}
>
{selectedSequenceId === sequence.id ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Session sélectionnée
</>
) : sequence.isBloquee ? (
"Inscriptions fermées"
) : (
"Choisir cette session"
)}
</Button>
</div> </div>
))} ))}
</div> </div>
@@ -349,16 +341,13 @@ export default function Inscription() {
{selectedSequenceId && ( {selectedSequenceId && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>2. Vos informations</CardTitle> <CardTitle>Vos informations</CardTitle>
<CardDescription> <CardDescription>
Complétez le formulaire pour finaliser votre inscription Complétez le formulaire pour finaliser votre inscription
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
<span className="font-semibold">Session sélectionnée :</span> {selectedSequence?.nom}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nom">Nom *</Label> <Label htmlFor="nom">Nom *</Label>
@@ -425,12 +414,12 @@ export default function Inscription() {
<Button <Button
type="submit" type="submit"
className="w-full text-base font-semibold shadow-sm" className="w-full"
disabled={inscriptionMutation.isPending || createApprenantMutation.isPending} disabled={inscriptionMutation.isPending || createApprenantMutation.isPending}
> >
{inscriptionMutation.isPending || createApprenantMutation.isPending {inscriptionMutation.isPending || createApprenantMutation.isPending
? "Inscription en cours..." ? "Inscription en cours..."
: "Finaliser mon inscription"} : "Confirmer mon inscription"}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>

View File

@@ -0,0 +1,116 @@
import { useLocation } from "wouter";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
import { KeyRound, Shield } from "lucide-react";
import { useEffect } from "react";
export default function LoginChoice() {
const [, setLocation] = useLocation();
const { user, loading, isAuthenticated } = useAuth();
// Rediriger les utilisateurs déjà connectés vers le tableau de bord
useEffect(() => {
if (!loading && isAuthenticated && user?.role === 'admin') {
setLocation('/admin');
}
}, [loading, isAuthenticated, user?.role, setLocation]);
const handleManusLogin = () => {
window.location.href = getLoginUrl();
};
const handleLocalLogin = () => {
setLocation("/login");
};
// Afficher un loader pendant la vérification de l'authentification
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// Ne pas afficher la page si l'utilisateur est déjà connecté (redirection en cours)
if (isAuthenticated && user?.role === 'admin') {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-4">
<div className="flex justify-center">
<img src={APP_LOGO} alt="Logo" className="h-20 w-auto" />
</div>
<div className="text-center">
<CardTitle className="page-title">{APP_TITLE}</CardTitle>
<CardDescription className="mt-3 text-base">
Choisissez votre méthode de connexion
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Connexion Manus OAuth */}
<Card className="border-2 hover:border-primary transition-colors cursor-pointer group" onClick={handleManusLogin}>
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-4">
<div className="p-4 bg-blue-100 rounded-full group-hover:bg-blue-200 transition-colors">
<Shield className="h-12 w-12 text-blue-600" />
</div>
</div>
<CardTitle className="text-xl">Connexion Manus</CardTitle>
<CardDescription className="mt-2">
Utilisez votre compte Manus (Google, Microsoft, Apple)
</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
size="lg"
onClick={handleManusLogin}
>
Se connecter avec Manus
</Button>
</CardContent>
</Card>
{/* Connexion Locale */}
<Card className="border-2 hover:border-primary transition-colors cursor-pointer group" onClick={handleLocalLogin}>
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-4">
<div className="p-4 bg-green-100 rounded-full group-hover:bg-green-200 transition-colors">
<KeyRound className="h-12 w-12 text-green-600" />
</div>
</div>
<CardTitle className="text-xl">Connexion Locale</CardTitle>
<CardDescription className="mt-2">
Utilisez votre identifiant et mot de passe
</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
variant="outline"
size="lg"
onClick={handleLocalLogin}
>
Se connecter localement
</Button>
</CardContent>
</Card>
</div>
<div className="mt-6 text-center text-sm text-gray-600">
<p>Vous n'avez pas de compte ?</p>
<p className="mt-1">Contactez l'administrateur</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,56 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_ID: ${VITE_APP_ID}
VITE_OAUTH_PORTAL_URL: ${VITE_OAUTH_PORTAL_URL}
VITE_APP_TITLE: ${VITE_APP_TITLE}
VITE_FRONTEND_FORGE_API_URL: ${VITE_FRONTEND_FORGE_API_URL}
VITE_FRONTEND_FORGE_API_KEY: ${VITE_FRONTEND_FORGE_API_KEY}
VITE_ANALYTICS_ENDPOINT: ${VITE_ANALYTICS_ENDPOINT}
VITE_ANALYTICS_WEBSITE_ID: ${VITE_ANALYTICS_WEBSITE_ID}
VITE_APP_LOGO: ${VITE_APP_LOGO}
image: formation-manager-itinova-app:v2
container_name: formation-manager-itinova
restart: unless-stopped
env_file:
- .env
environment:
APP_PUBLIC_URL: https://formations-itinova.santinova-soft.org
NODE_ENV: production
PORT: 3000
TRUST_PROXY: 1
volumes:
- /opt/manus-deploy/apps/formation-manager-itinova/uploads:/app/uploads
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.routers.formations-prod.entrypoints=websecure"
- "traefik.http.routers.formations-prod.rule=Host(`formations-itinova.santinova-soft.org`)"
- "traefik.http.routers.formations-prod.tls=true"
- "traefik.http.routers.formations-prod.tls.certresolver=letsencrypt"
- "traefik.http.routers.formations-prod.service=formations-prod-svc"
- "traefik.http.routers.formations-itinova.entrypoints=websecure"
- "traefik.http.routers.formations-itinova.rule=Host(`formations-itinova.santinova-soft.org`)"
- "traefik.http.routers.formations-itinova.tls=true"
- "traefik.http.routers.formations-itinova.tls.certresolver=letsencrypt"
- "traefik.http.routers.formations-itinova.service=formations-prod-svc"
# Alias historique : conserve le chemin et redirige définitivement vers l'URL Itinova canonique.
- "traefik.http.routers.formations-legacy-redirect.entrypoints=websecure"
- "traefik.http.routers.formations-legacy-redirect.rule=Host(`formations.santinova-soft.org`)"
- "traefik.http.routers.formations-legacy-redirect.tls=true"
- "traefik.http.routers.formations-legacy-redirect.tls.certresolver=letsencrypt"
- "traefik.http.routers.formations-legacy-redirect.middlewares=formations-legacy-redirect"
- "traefik.http.routers.formations-legacy-redirect.service=formations-prod-svc"
- "traefik.http.middlewares.formations-legacy-redirect.redirectregex.regex=^https://formations\\.santinova-soft\\.org/(.*)"
- "traefik.http.middlewares.formations-legacy-redirect.redirectregex.replacement=https://formations-itinova.santinova-soft.org/$${1}"
- "traefik.http.middlewares.formations-legacy-redirect.redirectregex.permanent=true"
- "traefik.http.services.formations-prod-svc.loadbalancer.server.port=3000"
networks:
web:
external: true

View File

@@ -38,8 +38,8 @@ export type InsertUser = typeof users.$inferInsert;
*/ */
export const passwordResetTokens = mysqlTable("passwordResetTokens", { export const passwordResetTokens = mysqlTable("passwordResetTokens", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
/** Utilisateur propriétaire du token. Ce contrat correspond à la table déployée. */ /** Email de l'utilisateur qui demande la réinitialisation */
userId: int("userId").notNull(), email: varchar("email", { length: 320 }).notNull(),
/** Token unique généré pour la réinitialisation */ /** Token unique généré pour la réinitialisation */
token: varchar("token", { length: 255 }).notNull().unique(), token: varchar("token", { length: 255 }).notNull().unique(),
/** Date d'expiration du token (24h après création) */ /** Date d'expiration du token (24h après création) */
@@ -143,8 +143,7 @@ export const inscriptions = mysqlTable("inscriptions", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
sequenceId: int("sequenceId").notNull(), sequenceId: int("sequenceId").notNull(),
apprenantId: int("apprenantId").notNull(), apprenantId: int("apprenantId").notNull(),
/** `liste_attente` est la valeur historique et réellement déployée dans les trois environnements. */ statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(),
statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(),
dateInscription: timestamp("dateInscription").defaultNow().notNull(), dateInscription: timestamp("dateInscription").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -191,31 +190,10 @@ export type InsertEmailConfig = typeof emailConfig.$inferInsert;
/** /**
* Table des templates d'emails * 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", { export const emailTemplates = mysqlTable("emailTemplates", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
/** Type de template (inscription, teaser, rappel1, rappel2, rappel3, rappel4, rappel5, rappel6, reset_password) */ /** Type de template (inscription, teaser, rappel1, rappel2, rappel3, rappel4, rappel5, rappel6, reset_password) */
type: mysqlEnum("type", emailTemplateTypes).notNull().unique(), type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "attestation"]).notNull().unique(),
titre: varchar("titre", { length: 255 }).notNull(), titre: varchar("titre", { length: 255 }).notNull(),
/** Corps du message avec variables dynamiques */ /** Corps du message avec variables dynamiques */
bodyContent: text("bodyContent").notNull(), bodyContent: text("bodyContent").notNull(),
@@ -334,13 +312,7 @@ export const questionnaires = mysqlTable("questionnaires", {
description: text("description"), description: text("description"),
/** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */ /** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */
type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(), 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(), 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(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
@@ -363,14 +335,9 @@ export const questions = mysqlTable("questions", {
valeurMin: int("valeurMin"), valeurMin: int("valeurMin"),
/** Valeur maximale pour les échelles */ /** Valeur maximale pour les échelles */
valeurMax: int("valeurMax"), 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(), obligatoire: boolean("obligatoire").default(false).notNull(),
ordre: int("ordre").notNull(), ordre: int("ordre").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}); });
export type Question = typeof questions.$inferSelect; export type Question = typeof questions.$inferSelect;
@@ -571,7 +538,7 @@ export type InsertLogNotification = typeof logsNotifications.$inferInsert;
export const parametres = mysqlTable("parametres", { export const parametres = mysqlTable("parametres", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
/** URL publique de l'application pour les QR codes d'émargement */ /** URL publique de l'application pour les QR codes d'émargement */
urlPublique: varchar("urlPublique", { length: 500 }).notNull().default("https://formations-itinova.santinova-soft.org"), urlPublique: varchar("urlPublique", { length: 500 }).notNull().default("https://formations.itinova.org"),
/** Délai d'expiration des QR codes en minutes (par défaut 30 minutes) */ /** Délai d'expiration des QR codes en minutes (par défaut 30 minutes) */
delaiExpirationQR: int("delaiExpirationQR").default(30).notNull(), delaiExpirationQR: int("delaiExpirationQR").default(30).notNull(),
/** Durée de validité des tokens d'émargement en heures (par défaut 24 heures) */ /** Durée de validité des tokens d'émargement en heures (par défaut 24 heures) */

View File

@@ -237,7 +237,7 @@ else
fi fi
execute_sql "Ajout de la colonne urlPublique" \ execute_sql "Ajout de la colonne urlPublique" \
"ALTER TABLE parametres ADD COLUMN urlPublique VARCHAR(500) NOT NULL DEFAULT 'https://formations-itinova.santinova-soft.org' AFTER id;" "ALTER TABLE parametres ADD COLUMN urlPublique VARCHAR(500) NOT NULL DEFAULT 'https://formations.itinova.org' AFTER id;"
execute_sql "Ajout de la colonne delaiExpirationQR" \ execute_sql "Ajout de la colonne delaiExpirationQR" \
"ALTER TABLE parametres ADD COLUMN delaiExpirationQR INT NOT NULL DEFAULT 30 AFTER urlPublique;" "ALTER TABLE parametres ADD COLUMN delaiExpirationQR INT NOT NULL DEFAULT 30 AFTER urlPublique;"

View File

@@ -11,7 +11,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"db:push": "drizzle-kit generate && drizzle-kit migrate", "db:push": "drizzle-kit generate && drizzle-kit migrate",
"verify": "pnpm check || true && pnpm test && pnpm build" "verify": "pnpm check && pnpm test && pnpm build"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/client-s3": "^3.693.0",

View File

@@ -20,7 +20,7 @@ if [ ! -f "$ENV_FILE" ]; then
fi fi
# Demander l'URL publique # Demander l'URL publique
echo "Entrez l'URL publique de votre application (ex: https://formations-itinova.santinova-soft.org) :" echo "Entrez l'URL publique de votre application (ex: https://formations.itinova.org) :"
read PUBLIC_URL read PUBLIC_URL
# Vérifier que l'URL commence par http:// ou https:// # Vérifier que l'URL commence par http:// ou https://

View File

@@ -1,51 +0,0 @@
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-itinova.recette.santinova-soft.org/reset-password?token=abc123";
const result = replaceEmailVariables(
'<a href="{{lienReinitialisation}}">Réinitialiser</a>',
{ 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: "<p>Réinitialisation mot de passe</p>",
couleurPrincipale: "#283581",
couleurSecondaire: "#0578BE",
piedDePage: null,
});
const { generateEmailFromTemplate } = await import("../emailTemplateGenerator");
const resetLink = "https://formations-itinova.recette.santinova-soft.org/reset-password?token=abc123";
const html = await generateEmailFromTemplate(
"reset_password",
`<a href="${resetLink}">Réinitialiser</a>`,
{ lienReinitialisation: resetLink },
);
expect(html).toContain("Réinitialisation mot de passe");
expect(html).toContain(`href="${resetLink}"`);
});
});

View File

@@ -1,66 +0,0 @@
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));
}
});
});

View File

@@ -1,104 +0,0 @@
import { readFileSync } from "node:fs";
import * as XLSX from "xlsx";
import { describe, expect, it } from "vitest";
import { generateApprenantsExcelExport } from "../apprenantExportService";
import { sortSequencesForRegistration } from "../../shared/registration";
const inscriptionSource = readFileSync(
new URL("../../client/src/pages/Inscription.tsx", import.meta.url),
"utf8",
);
const sequencesSource = readFileSync(
new URL("../../client/src/pages/AdminSequences.tsx", import.meta.url),
"utf8",
);
describe("parcours d'inscription", () => {
it("place les sessions avec places disponibles avant les sessions complètes ou fermées", () => {
const now = new Date("2026-10-01T10:00:00.000Z");
const sorted = sortSequencesForRegistration(
[
{
id: 1,
nom: "Session fermée",
statut: "bloquee",
dateBlocage: null,
capaciteMax: 12,
nbInscrits: 0,
dates: [{ dateDebut: "2026-10-04T09:00:00.000Z" }],
},
{
id: 2,
nom: "Session complète",
statut: "ouverte",
dateBlocage: null,
capaciteMax: 12,
nbInscrits: 12,
dates: [{ dateDebut: "2026-10-03T09:00:00.000Z" }],
},
{
id: 3,
nom: "Session disponible tardive",
statut: "ouverte",
dateBlocage: null,
capaciteMax: 12,
nbInscrits: 3,
dates: [{ dateDebut: "2026-10-05T09:00:00.000Z" }],
},
{
id: 4,
nom: "Session disponible prioritaire",
statut: "ouverte",
dateBlocage: null,
capaciteMax: 12,
nbInscrits: 2,
dates: [{ dateDebut: "2026-10-02T09:00:00.000Z" }],
},
],
now,
);
expect(sorted.map((sequence) => sequence.id)).toEqual([4, 3, 2, 1]);
expect(sorted[0]).toMatchObject({ isBloquee: false, placesRestantes: 10 });
expect(sorted[3]).toMatchObject({ isBloquee: true });
});
it("présente un bouton explicite de sélection de session et le libellé Tous managers", () => {
expect(inscriptionSource).toContain("Choisir cette session");
expect(inscriptionSource).toContain("Session sélectionnée");
expect(inscriptionSource).toContain("Tous managers");
expect(sequencesSource).toContain("Tous managers");
});
});
describe("export Excel des apprenants", () => {
it("génère les colonnes nom, prénom et formation suivie", () => {
const buffer = generateApprenantsExcelExport([
{ nom: "Durand", prenom: "Alice", formationSuivie: "Manager de proximité" },
{ nom: "Martin", prenom: "Bruno", formationSuivie: "Gestion de projet | Communication" },
]);
const workbook = XLSX.read(buffer, { type: "buffer" });
expect(workbook.SheetNames).toEqual(["Apprenants"]);
const rows = XLSX.utils.sheet_to_json<string[]>(workbook.Sheets.Apprenants, {
header: 1,
raw: false,
});
expect(rows).toEqual([
["Nom", "Prénom", "Formation suivie"],
["Durand", "Alice", "Manager de proximité"],
["Martin", "Bruno", "Gestion de projet | Communication"],
]);
});
it("neutralise les valeurs interprétables comme formules Excel", () => {
const buffer = generateApprenantsExcelExport([
{ nom: "=HYPERLINK(\"https://example.org\")", prenom: "Alice", formationSuivie: "Formation test" },
]);
const workbook = XLSX.read(buffer, { type: "buffer" });
const sheet = workbook.Sheets.Apprenants;
expect(sheet.A2?.v).toBe("'=HYPERLINK(\"https://example.org\")");
});
});

View File

@@ -3,6 +3,8 @@ import fs from "fs";
import { type Server } from "http"; import { type Server } from "http";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import path from "path"; import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) { export async function setupVite(app: Express, server: Server) {
const serverOptions = { const serverOptions = {
@@ -11,14 +13,6 @@ export async function setupVite(app: Express, server: Server) {
allowedHosts: true as const, allowedHosts: true as const,
}; };
// Vite et sa configuration ne sont requis qu'en développement. Les spécificateurs dynamiques empêchent esbuild de les lier au serveur de production.
const vitePackage = "vite";
const viteConfigModule = "../../vite.config";
const [{ createServer: createViteServer }, { default: viteConfig }] = await Promise.all([
import(vitePackage),
import(viteConfigModule),
]);
const vite = await createViteServer({ const vite = await createViteServer({
...viteConfig, ...viteConfig,
configFile: false, configFile: false,

View File

@@ -1,35 +0,0 @@
import * as XLSX from "xlsx";
export type ApprenantExcelExport = {
nom: string;
prenom: string;
formationSuivie: string;
};
/**
* Empêche qu'une valeur saisie par un utilisateur soit interprétée comme une formule
* lorsqu'elle est ouverte dans Excel ou LibreOffice.
*/
function spreadsheetSafeText(value: string): string {
return /^[=+\-@]/.test(value) ? `'${value}` : value;
}
/** Génère la liste des apprenants et des formations auxquelles ils sont inscrits. */
export function generateApprenantsExcelExport(apprenants: readonly ApprenantExcelExport[]): Buffer {
const rows = [
["Nom", "Prénom", "Formation suivie"],
...apprenants.map((apprenant) => [
spreadsheetSafeText(apprenant.nom),
spreadsheetSafeText(apprenant.prenom),
spreadsheetSafeText(apprenant.formationSuivie),
]),
];
const worksheet = XLSX.utils.aoa_to_sheet(rows);
worksheet["!cols"] = [{ wch: 24 }, { wch: 24 }, { wch: 48 }];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Apprenants");
return Buffer.from(XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }));
}

View File

@@ -23,8 +23,6 @@ import {
Apprenant, Apprenant,
Formation, Formation,
EmailTemplate, EmailTemplate,
EmailTemplateType,
isEmailTemplateType,
InsertEmailTemplate, InsertEmailTemplate,
formateurs, formateurs,
InsertFormateur, InsertFormateur,
@@ -381,57 +379,6 @@ export async function getApprenants() {
return apprenantsWithInscriptions; return apprenantsWithInscriptions;
} }
/**
* Retourne une ligne par apprenant avec les formations non annulées auxquelles il est inscrit.
* La requête joint les tables en une fois afin d'éviter une requête par apprenant à l'export.
*/
export async function getApprenantsWithFormationNames() {
const db = await getDb();
if (!db) return [];
const rows = await db
.select({
id: apprenants.id,
nom: apprenants.nom,
prenom: apprenants.prenom,
formationNom: formations.nom,
inscriptionStatut: inscriptions.statut,
})
.from(apprenants)
.leftJoin(inscriptions, eq(inscriptions.apprenantId, apprenants.id))
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id));
const byApprenant = new Map<number, { nom: string; prenom: string; formations: Set<string> }>();
for (const row of rows) {
const entry = byApprenant.get(row.id) ?? {
nom: row.nom,
prenom: row.prenom,
formations: new Set<string>(),
};
if (row.inscriptionStatut !== "annulee" && row.formationNom) {
entry.formations.add(row.formationNom);
}
byApprenant.set(row.id, entry);
}
return [...byApprenant.values()]
.map((apprenant) => ({
nom: apprenant.nom,
prenom: apprenant.prenom,
formationSuivie: [...apprenant.formations]
.sort((left, right) => left.localeCompare(right, "fr", { sensitivity: "base" }))
.join(" | "),
}))
.sort((left, right) => {
const nomOrder = left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" });
return nomOrder !== 0
? nomOrder
: left.prenom.localeCompare(right.prenom, "fr", { sensitivity: "base" });
});
}
export async function getApprenantsByFormateur(formateurId: number) { export async function getApprenantsByFormateur(formateurId: number) {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
@@ -504,14 +451,7 @@ export async function updateApprenant(id: number, data: Partial<InsertApprenant>
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
try {
await db.update(apprenants).set(data).where(eq(apprenants.id, id)); await db.update(apprenants).set(data).where(eq(apprenants.id, id));
} catch (error: any) {
if (error?.code === 'ER_DUP_ENTRY' || error?.message?.includes('Duplicate entry')) {
throw new Error("Cet email est déjà utilisé par un autre apprenant.");
}
throw error;
}
} }
export async function deleteApprenant(id: number) { export async function deleteApprenant(id: number) {
@@ -736,14 +676,22 @@ export async function deleteUser(id: number) {
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ==================== // ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
export async function createPasswordResetToken(userId: number, token: string) { export async function createPasswordResetToken(email: string, token: string) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
// Un Date est converti par Drizzle avec le fuseau du serveur sans manipulation de chaîne. const expiresAt = new Date();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); expiresAt.setHours(expiresAt.getHours() + 24); // Token valide 24h
return db.insert(passwordResetTokens).values({ userId, token, expiresAt }); // Convertir la date au format MySQL DATETIME (YYYY-MM-DD HH:MM:SS)
const mysqlDate = expiresAt.toISOString().slice(0, 19).replace('T', ' ');
// Utiliser une requête SQL brute pour éviter les problèmes avec Drizzle
const result = await db.execute(sql`
INSERT INTO passwordResetTokens (email, token, expiresAt)
VALUES (${email}, ${token}, ${mysqlDate})
`);
return result;
} }
export async function getPasswordResetToken(token: string) { export async function getPasswordResetToken(token: string) {
@@ -797,10 +745,6 @@ export async function getEmailTemplateByType(type: string) {
const db = await getDb(); const db = await getDb();
if (!db) return null; if (!db) return null;
// Les services de notification peuvent demander un template non persistant.
// Dans ce cas, le générateur retombe volontairement sur son HTML de secours.
if (!isEmailTemplateType(type)) return null;
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1); const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
return results.length > 0 ? results[0] : null; return results.length > 0 ? results[0] : null;
} }
@@ -842,10 +786,6 @@ export async function deleteEmailTemplate(type: string) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
if (!isEmailTemplateType(type)) {
throw new Error("Type de template email invalide");
}
await db.delete(emailTemplates).where(eq(emailTemplates.type, type)); await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
} }
@@ -1018,14 +958,7 @@ export async function initializeDefaultEmailTemplates() {
{ {
type: "reset_password", type: "reset_password",
titre: "Réinitialisation de mot de passe", titre: "Réinitialisation de mot de passe",
// Le lien doit rester une variable : chaque demande génère un token différent. bodyContent: "<p>Contenu par défaut</p>",
bodyContent: `<p>Bonjour {{prenomApprenant}} {{nomApprenant}},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.</p>
<p>Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<p style="text-align: center;"><a href="{{lienReinitialisation}}" class="button">Réinitialiser mon mot de passe</a></p>
<p>Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all;">{{lienReinitialisation}}</p>
<p>Ce lien est valide pendant 24 heures et ne peut être utilisé qu’une seule fois.</p>`,
couleurPrincipale: "#283581", couleurPrincipale: "#283581",
couleurSecondaire: "#0578BE", 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.", piedDePage: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",

View File

@@ -8,8 +8,6 @@ import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_c
import { generateEmailFromTemplate } from "./emailTemplateGenerator"; import { generateEmailFromTemplate } from "./emailTemplateGenerator";
import { EmailVariables } from "./emailTemplateUtils"; import { EmailVariables } from "./emailTemplateUtils";
type GroupEmailType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6";
interface EmailParams { interface EmailParams {
to: string; to: string;
subject: string; subject: string;
@@ -614,10 +612,10 @@ export async function sendRappelJ1Email(params: {
* Envoie des emails groupés à tous les inscrits d'une séquence * Envoie des emails groupés à tous les inscrits d'une séquence
*/ */
export async function sendGroupEmail(params: { export async function sendGroupEmail(params: {
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string | null }>; recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
formationNom: string; formationNom: string;
sequenceNom: string; sequenceNom: string;
type: GroupEmailType; type: 'teaser' | 'rappel' | 'rappel_j1';
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string; lieu?: string;
formateur?: string; formateur?: string;
@@ -632,26 +630,37 @@ export async function sendGroupEmail(params: {
apprenantEmail: recipient.email, apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom, apprenantPrenom: recipient.prenom,
apprenantNom: recipient.nom, apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction || '', apprenantFonction: recipient.fonction,
formationNom: params.formationNom, formationNom: params.formationNom,
sequenceNom: params.sequenceNom, sequenceNom: params.sequenceNom,
dates: params.dates, dates: params.dates,
lieu: params.lieu, lieu: params.lieu,
formateur: params.formateur, formateur: params.formateur,
}); });
} else { } else if (params.type === 'rappel') {
// Un seul chemin pour les six rappels : le contenu est celui du template choisi. await sendRappelJ7Email({
await sendRappelEmail({
apprenantEmail: recipient.email, apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom, apprenantPrenom: recipient.prenom,
apprenantNom: recipient.nom, apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction || '', apprenantFonction: recipient.fonction,
formationNom: params.formationNom, formationNom: params.formationNom,
sequenceNom: params.sequenceNom, sequenceNom: params.sequenceNom,
dates: params.dates, dates: params.dates,
lieu: params.lieu || '', lieu: params.lieu || '',
formateur: params.formateur, formateur: params.formateur,
templateType: params.type, });
} 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,
}); });
} }
sent++; sent++;
@@ -705,13 +714,7 @@ export async function sendPasswordResetEmail(params: {
return sendEmail({ return sendEmail({
to: params.apprenantEmail, to: params.apprenantEmail,
subject: 'Réinitialisation de votre mot de passe - Manager Itinova', subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
// Le modèle reset_password doit recevoir le lien : son bodyContent est personnalisable html: await getEmailTemplate(content, 'reset_password'),
// 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,
}),
}); });
} }

View File

@@ -37,13 +37,6 @@ function buildEmailHTML(template: EmailTemplate, content: string, variables?: Em
// Utiliser bodyContent si disponible, sinon utiliser content (legacy) // Utiliser bodyContent si disponible, sinon utiliser content (legacy)
let emailBody = template.bodyContent || content || ''; 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 // Remplacer les variables si fournies
if (variables && emailBody) { if (variables && emailBody) {
emailBody = replaceEmailVariables(emailBody, variables); emailBody = replaceEmailVariables(emailBody, variables);

View File

@@ -13,7 +13,6 @@ export interface EmailVariables {
lieu?: string; lieu?: string;
formateur?: string; formateur?: string;
lienInscription?: string; lienInscription?: string;
lienReinitialisation?: string;
[key: string]: string | undefined; [key: string]: string | undefined;
} }
@@ -66,7 +65,6 @@ export function generateEmailPreview(template: string): string {
lieu: 'Salle de formation A - Bâtiment principal', lieu: 'Salle de formation A - Bâtiment principal',
formateur: 'Jean Martin', formateur: 'Jean Martin',
lienInscription: 'https://exemple.com/inscription/abc123', lienInscription: 'https://exemple.com/inscription/abc123',
lienReinitialisation: 'https://exemple.com/reset-password?token=abc123',
}; };
return replaceEmailVariables(template, exampleVariables); return replaceEmailVariables(template, exampleVariables);

View File

@@ -80,7 +80,7 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
{ 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom }, { 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom },
{ 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom }, { 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom },
{ 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu }, { 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu },
{ 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre' }, { 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' },
{ 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() }, { 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() },
]; ];
@@ -126,7 +126,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
yPos += 6; yPos += 6;
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
yPos += 6; yPos += 6;
doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos); doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
yPos += 8; yPos += 8;
// Dates de formation // Dates de formation
@@ -260,7 +260,7 @@ export function generateFeuillePresenceJour(
const publicCibleLabel = const publicCibleLabel =
feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'directeur' ? 'Directeurs' :
feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' :
feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'; feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre';
const fonctionLabel = (f: string | null) => const fonctionLabel = (f: string | null) =>
f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre'; f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre';
@@ -434,7 +434,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
} }
// Fonction pour générer une page de feuille de présence pour une journée // Fonction pour générer une page de feuille de présence pour une journée
const generatePageForDate = (dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => { const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => {
if (!isFirstPage) { if (!isFirstPage) {
doc.addPage(); doc.addPage();
} }
@@ -464,7 +464,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe
yPos += 6; yPos += 6;
} }
doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos); doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos);
yPos += 8; yPos += 8;
// Date de la journée // Date de la journée

View File

@@ -15,7 +15,7 @@ export async function getParametres() {
if (result.length === 0) { if (result.length === 0) {
// Créer les paramètres par défaut // Créer les paramètres par défaut
await db.insert(parametres).values({ await db.insert(parametres).values({
urlPublique: "https://formations-itinova.santinova-soft.org", urlPublique: "https://formations.itinova.org",
}); });
const newResult = await db.select().from(parametres).limit(1); const newResult = await db.select().from(parametres).limit(1);

View File

@@ -12,7 +12,6 @@ import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
import { logNotification } from "./notificationLogsDb"; import { logNotification } from "./notificationLogsDb";
import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService"; import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService";
import { generateApprenantsExcelExport } from "./apprenantExportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
import crypto from "crypto"; import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -20,23 +19,6 @@ import { sql } from "drizzle-orm";
import { catalogueRouter } from "./routers/catalogue"; import { catalogueRouter } from "./routers/catalogue";
import { planFormationRouter } from "./routers/planFormation"; 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 // Procédure admin uniquement
const adminProcedure = protectedProcedure.use(({ ctx, next }) => { const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') { if (ctx.user.role !== 'admin') {
@@ -70,7 +52,7 @@ export const appRouter = router({
.input(z.object({ .input(z.object({
email: z.string().email(), email: z.string().email(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const { email } = input; const { email } = input;
// Vérifier si l'utilisateur existe // Vérifier si l'utilisateur existe
@@ -84,10 +66,10 @@ export const appRouter = router({
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
// Sauvegarder le token dans la base de données // Sauvegarder le token dans la base de données
await db.createPasswordResetToken(utilisateur.id, token); await db.createPasswordResetToken(email, token);
// Envoyer l'email avec le template reset_password // Envoyer l'email avec le template reset_password
const resetUrl = buildPasswordResetUrl(ctx.req, token); const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
await sendResetPasswordEmail({ await sendResetPasswordEmail({
email, email,
@@ -135,7 +117,7 @@ export const appRouter = router({
} }
// Récupérer l'utilisateur // Récupérer l'utilisateur
const utilisateur = await db.getUserById(resetToken.userId); const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
if (!utilisateur) { if (!utilisateur) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -327,17 +309,6 @@ export const appRouter = router({
return { success: true }; return { success: true };
}), }),
exportExcel: adminProcedure.mutation(async () => {
const apprenants = await db.getApprenantsWithFormationNames();
const buffer = generateApprenantsExcelExport(apprenants);
const exportDate = new Date().toISOString().slice(0, 10);
return {
buffer: buffer.toString("base64"),
filename: `apprenants_${exportDate}.xlsx`,
};
}),
getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const results = await db.getInscriptionsByApprenant(input.id); const results = await db.getInscriptionsByApprenant(input.id);
// Récupérer les informations de formation et séquence pour chaque inscription // Récupérer les informations de formation et séquence pour chaque inscription
@@ -696,8 +667,7 @@ export const appRouter = router({
// Vérifier si la séquence est bloquée // Vérifier si la séquence est bloquée
const now = new Date(); const now = new Date();
// Une date de blocage est optionnelle : ne jamais transformer `null` en 01/01/1970. if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)' message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
@@ -715,7 +685,7 @@ export const appRouter = router({
// Vérifier la capacité // Vérifier la capacité
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee'; const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee';
await db.createInscription({ await db.createInscription({
apprenantId: input.apprenantId, apprenantId: input.apprenantId,
@@ -815,7 +785,7 @@ export const appRouter = router({
const admins = await db.getAdminUsers(); const admins = await db.getAdminUsers();
const adminEmails = admins.filter(a => a.email).map(a => a.email!); const adminEmails = admins.filter(a => a.email).map(a => a.email!);
if (adminEmails.length > 0) { if (adminEmails.length > 0) {
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente'); const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente');
let formateurNom: string | undefined; let formateurNom: string | undefined;
if (inscriptionSequence.formateurId) { if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId); const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
@@ -880,7 +850,7 @@ export const appRouter = router({
// Vérifier si la séquence est bloquée // Vérifier si la séquence est bloquée
const now = new Date(); const now = new Date();
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) { if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)' message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
@@ -957,7 +927,7 @@ export const appRouter = router({
// Notifier le premier en liste d'attente qu'une place s'est libérée // Notifier le premier en liste d'attente qu'une place s'est libérée
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId); const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
const premierEnAttente = inscriptionsListeAttente const premierEnAttente = inscriptionsListeAttente
.filter(i => i.inscription.statut === 'liste_attente') .filter(i => i.inscription.statut === 'en_attente')
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0]; .sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
if (premierEnAttente && premierEnAttente.apprenant) { if (premierEnAttente && premierEnAttente.apprenant) {
@@ -1003,7 +973,7 @@ export const appRouter = router({
updateStatut: adminProcedure.input(z.object({ updateStatut: adminProcedure.input(z.object({
id: z.number(), id: z.number(),
statut: z.enum(['confirmee', 'liste_attente', 'annulee']), statut: z.enum(['confirmee', 'en_attente', 'annulee']),
})).mutation(async ({ input }) => { })).mutation(async ({ input }) => {
// Récupérer l'inscription avant modification // Récupérer l'inscription avant modification
const inscriptionAvant = await db.getInscriptionById(input.id); const inscriptionAvant = await db.getInscriptionById(input.id);
@@ -1323,7 +1293,7 @@ export const appRouter = router({
await db.createInscription({ await db.createInscription({
apprenantId: input.apprenantId, apprenantId: input.apprenantId,
sequenceId: input.sequenceId, sequenceId: input.sequenceId,
statut: input.statut === "confirme" ? "confirmee" : "liste_attente", statut: input.statut === "confirme" ? "confirmee" : "en_attente",
}); });
return { success: true }; return { success: true };
@@ -1414,7 +1384,7 @@ export const appRouter = router({
return { success: true }; return { success: true };
}), }),
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input, ctx }) => { requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const user = await db.getUserById(input.id); const user = await db.getUserById(input.id);
if (!user) { if (!user) {
throw new Error("Utilisateur introuvable"); throw new Error("Utilisateur introuvable");
@@ -1429,10 +1399,11 @@ export const appRouter = router({
const token = crypto.randomBytes(32).toString('hex'); const token = crypto.randomBytes(32).toString('hex');
// Sauvegarder le token en base (la fonction gère l'expiration automatiquement) // Sauvegarder le token en base (la fonction gère l'expiration automatiquement)
await db.createPasswordResetToken(user.id, token); await db.createPasswordResetToken(user.email, token);
// Générer le lien de réinitialisation sur l’environnement réellement appelé. // Générer le lien de réinitialisation
const resetLink = buildPasswordResetUrl(ctx.req, token); // TODO: Remplacer par l'URL réelle de votre application en production
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
// Envoyer l'email // Envoyer l'email
const emailService = await import('./emailService'); const emailService = await import('./emailService');
@@ -2086,8 +2057,8 @@ export const appRouter = router({
texte: z.string(), texte: z.string(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]), typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
options: z.string().optional(), options: z.string().optional(),
valeurMin: z.number().optional(), echelleMin: z.number().optional(),
valeurMax: z.number().optional(), echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(), echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(), echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().default(false), obligatoire: z.boolean().default(false),
@@ -2105,8 +2076,8 @@ export const appRouter = router({
texte: z.string().optional(), texte: z.string().optional(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(), typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
options: z.string().optional(), options: z.string().optional(),
valeurMin: z.number().optional(), echelleMin: z.number().optional(),
valeurMax: z.number().optional(), echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(), echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(), echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().optional(), obligatoire: z.boolean().optional(),

View File

@@ -1,55 +0,0 @@
/**
* Détermine l'ordre d'affichage des séquences sur le lien d'inscription public.
* Les sessions ouvertes avec des places disponibles sont toujours proposées en premier.
*/
export type RegistrationSequence = {
id: number;
nom: string;
statut: string;
dateBlocage?: Date | string | null;
capaciteMax: number;
nbInscrits?: number | null;
dates?: ReadonlyArray<{ dateDebut: Date | string }>;
};
export type RegistrationSequenceWithAvailability<T extends RegistrationSequence> = T & {
isBloquee: boolean;
placesRestantes: number;
};
function firstDateTimestamp(sequence: RegistrationSequence): number {
const firstDate = sequence.dates?.[0]?.dateDebut;
if (!firstDate) return Number.MAX_SAFE_INTEGER;
const timestamp = new Date(firstDate).getTime();
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER;
}
function displayPriority(sequence: RegistrationSequenceWithAvailability<RegistrationSequence>): number {
if (sequence.isBloquee) return 2;
return sequence.placesRestantes > 0 ? 0 : 1;
}
export function sortSequencesForRegistration<T extends RegistrationSequence>(
sequences: readonly T[],
now = new Date(),
): RegistrationSequenceWithAvailability<T>[] {
return sequences
.map((sequence) => {
const dateBlocage = sequence.dateBlocage ? new Date(sequence.dateBlocage) : null;
const hasReachedBlockingDate = dateBlocage !== null && now >= dateBlocage;
const isBloquee = sequence.statut !== "ouverte" || hasReachedBlockingDate;
const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits ?? 0));
return { ...sequence, isBloquee, placesRestantes };
})
.sort((left, right) => {
const priorityDifference = displayPriority(left) - displayPriority(right);
if (priorityDifference !== 0) return priorityDifference;
const dateDifference = firstDateTimestamp(left) - firstDateTimestamp(right);
if (dateDifference !== 0) return dateDifference;
return left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" });
});
}

View File

@@ -1,30 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const compose = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8");
assert.match(
compose,
/traefik\.http\.routers\.formations-legacy-redirect\.rule=Host\(`formations\.santinova-soft\.org`\)/,
"Le domaine historique doit avoir son routeur Traefik dédié.",
);
assert.match(
compose,
/traefik\.http\.routers\.formations-legacy-redirect\.middlewares=formations-legacy-redirect/,
"Le routeur historique doit appliquer le middleware de redirection.",
);
assert.match(
compose,
/traefik\.http\.middlewares\.formations-legacy-redirect\.redirectregex\.regex=\^https:\/\/formations\\\\\.santinova-soft\\\\\.org\/\(\.\*\)/,
"La redirection doit cibler uniquement le domaine historique en HTTPS.",
);
assert.match(
compose,
/traefik\.http\.middlewares\.formations-legacy-redirect\.redirectregex\.replacement=https:\/\/formations-itinova\.santinova-soft\.org\/\$\$\{1\}/,
"La redirection doit conserver le chemin vers l’URL canonique Itinova.",
);
assert.match(
compose,
/traefik\.http\.middlewares\.formations-legacy-redirect\.redirectregex\.permanent=true/,
"La redirection doit être permanente.",
);

View File

@@ -689,7 +689,7 @@
- [x] Créer la page AdminParametres - [x] Créer la page AdminParametres
- [x] Ajouter le lien dans le menu après "Configuration SMTP" - [x] Ajouter le lien dans le menu après "Configuration SMTP"
- [x] Modifier qrCodeGenerator pour utiliser l'URL de la BDD - [x] Modifier qrCodeGenerator pour utiliser l'URL de la BDD
- [x] Définir l'URL par défaut : https://formations-itinova.santinova-soft.org - [x] Définir l'URL par défaut : https://formations.itinova.org
- [x] Ajouter DashboardLayout à la page AdminParametres - [x] Ajouter DashboardLayout à la page AdminParametres
- [x] Ajouter DashboardLayout à la page FormateurEmargement - [x] Ajouter DashboardLayout à la page FormateurEmargement