Compare commits
1 Commits
main
...
ba9052d87f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9052d87f |
@@ -1,12 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
uploads
|
|
||||||
mysql-data
|
|
||||||
src
|
|
||||||
deployment-package
|
|
||||||
*.tar.gz
|
|
||||||
*.pdf
|
|
||||||
*.docx
|
|
||||||
@@ -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 :
|
||||||
|
|||||||
45
Dockerfile
45
Dockerfile
@@ -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"]
|
|
||||||
@@ -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é
|
||||||
|
|||||||
4
app.json
4
app.json
@@ -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",
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
116
client/src/pages/LoginChoice.tsx
Normal file
116
client/src/pages/LoginChoice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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;"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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://
|
||||||
|
|||||||
@@ -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}"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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\")");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }));
|
|
||||||
}
|
|
||||||
93
server/db.ts
93
server/db.ts
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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.",
|
|
||||||
);
|
|
||||||
2
todo.md
2
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user