diff --git a/.manus/db/db-query-1770056170569.json b/.manus/db/db-query-1770056170569.json deleted file mode 100644 index bd45008..0000000 --- a/.manus/db/db-query-1770056170569.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "query": "ALTER TABLE `presences` MODIFY COLUMN `inscriptionId` int;\nALTER TABLE `presences` ADD `typeUtilisateur` enum('apprenant','formateur') DEFAULT 'apprenant' NOT NULL;\nALTER TABLE `presences` ADD `formateurId` int;", - "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE `presences` MODIFY COLUMN `inscriptionId` int;\nALTER TABLE `presences` ADD `typeUtilisateur` enum('apprenant','formateur') DEFAULT 'apprenant' NOT NULL;\nALTER TABLE `presences` ADD `formateurId` int;", - "rows": [], - "messages": [], - "stdout": "", - "stderr": "", - "execution_time_ms": 1467 -} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 3709e59..dce7178 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,7 +5,6 @@ import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; -import EmargementFormateurScan from "./pages/EmargementFormateurScan"; import LoginChoice from "./pages/LoginChoice"; import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import Admin from "./pages/Admin"; @@ -49,9 +48,8 @@ function Router() { return ( - - - + + diff --git a/client/src/pages/EmargementFormateurScan.tsx b/client/src/pages/EmargementFormateurScan.tsx deleted file mode 100644 index a70ac2a..0000000 --- a/client/src/pages/EmargementFormateurScan.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { trpc } from "@/lib/trpc"; -import { CheckCircle2, Loader2, XCircle } from "lucide-react"; -import { useState } from "react"; -import { useParams } from "wouter"; -import { toast } from "sonner"; -import SignaturePadComponent from "@/components/SignaturePad"; - -export default function EmargementFormateurScan() { - const { token } = useParams<{ token: string }>(); - const [email, setEmail] = useState(""); - const [emailConfirmed, setEmailConfirmed] = useState(false); - const [selectedPeriode, setSelectedPeriode] = useState<"matin" | "apres_midi">("matin"); - const [success, setSuccess] = useState(false); - const [heureEnregistrement, setHeureEnregistrement] = useState(null); - const [error, setError] = useState(null); - const [showSignaturePad, setShowSignaturePad] = useState(false); - - // Valider la présence formateur - const { mutate: validerPresence, isPending: validating } = trpc.presences.validerFormateur.useMutation({ - onSuccess: () => { - const now = new Date(); - setHeureEnregistrement(now); - setSuccess(true); - toast.success("Présence validée avec succès !"); - }, - onError: (error) => { - setError(error.message); - toast.error("Erreur lors de la validation", { - description: error.message, - }); - }, - }); - - const handleSaveSignature = (dataUrl: string) => { - setShowSignaturePad(false); - // Valider automatiquement après la signature - validerPresence({ - token: token || "", - email, - periode: selectedPeriode, - signatureDataUrl: dataUrl, - }); - }; - - const handleSearchEmail = () => { - if (!email || !email.includes("@")) { - toast.error("Veuillez saisir un email valide"); - return; - } - setEmailConfirmed(true); - }; - - const handleValider = () => { - setShowSignaturePad(true); - }; - - if (!token) { - return ( -
- - - - - Lien invalide - - Le lien d'émargement est invalide ou a expiré. - - -
- ); - } - - if (showSignaturePad) { - return ( -
- - - Signature de présence formateur - - Signez pour valider votre présence en tant que formateur - - - - setShowSignaturePad(false)} - /> - - -
- ); - } - - if (success) { - return ( -
- - - - - Présence validée - - - Votre présence a été enregistrée avec succès - {heureEnregistrement && ( - - Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")} - - )} - - - -
- ); - } - - if (error) { - return ( -
- - - - - Erreur - - {error} - - - - - -
- ); - } - - return ( -
- - - Émargement formateur - Validez votre présence en tant que formateur - - - {!emailConfirmed ? ( -
-
- - setEmail(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSearchEmail(); - } - }} - /> -
- -
- ) : ( -
-
-
- 👨‍🏫 - Mode formateur -
-

{email}

-
- -
- - -
- - -
- )} -
-
-
- ); -} diff --git a/client/src/pages/EmargementScan.tsx b/client/src/pages/EmargementScan.tsx index 27c7b1b..283aab3 100644 --- a/client/src/pages/EmargementScan.tsx +++ b/client/src/pages/EmargementScan.tsx @@ -10,8 +10,8 @@ import { SelectValue, } from "@/components/ui/select"; import { trpc } from "@/lib/trpc"; -import { CheckCircle2, Loader2, XCircle } from "lucide-react"; -import { useState } from "react"; +import { CheckCircle2, Loader2, XCircle, PenTool } from "lucide-react"; +import { useEffect, useState } from "react"; import { useParams } from "wouter"; import { toast } from "sonner"; import SignaturePadComponent from "@/components/SignaturePad"; @@ -19,7 +19,6 @@ import SignaturePadComponent from "@/components/SignaturePad"; export default function EmargementScan() { const { token } = useParams<{ token: string }>(); const [email, setEmail] = useState(""); - const [emailConfirmed, setEmailConfirmed] = useState(false); const [selectedInscriptionId, setSelectedInscriptionId] = useState(null); const [selectedDateId, setSelectedDateId] = useState(null); const [selectedPeriode, setSelectedPeriode] = useState<"matin" | "apres_midi">("matin"); @@ -27,7 +26,7 @@ export default function EmargementScan() { const [heureEnregistrement, setHeureEnregistrement] = useState(null); const [error, setError] = useState(null); const [showSignaturePad, setShowSignaturePad] = useState(false); - const [isFormateur, setIsFormateur] = useState(false); + const [signatureDataUrl, setSignatureDataUrl] = useState(null); // Récupérer l'apprenant par email const { data: apprenant, refetch: refetchApprenant } = trpc.apprenants.getByEmail.useQuery( @@ -38,7 +37,7 @@ export default function EmargementScan() { // Récupérer les inscriptions de l'apprenant avec les dates const { data: inscriptions } = trpc.inscriptions.listByApprenantWithDates.useQuery( { apprenantId: apprenant?.id || 0 }, - { enabled: !!apprenant && !isFormateur } + { enabled: !!apprenant } ); // Récupérer les dates de formation pour l'inscription sélectionnée @@ -61,130 +60,105 @@ export default function EmargementScan() { }); const handleSaveSignature = (dataUrl: string) => { + setSignatureDataUrl(dataUrl); setShowSignaturePad(false); // Valider automatiquement après la signature handleValiderWithSignature(dataUrl); }; const handleSearchEmail = async () => { - if (!email || !email.includes("@")) { - toast.error("Veuillez saisir un email valide"); + if (!email) { + toast.error("Veuillez saisir votre email"); return; } - - // Essayer de récupérer l'apprenant - const result = await refetchApprenant(); - - if (result.data) { - // C'est un apprenant - setIsFormateur(false); - setEmailConfirmed(true); - } else { - // C'est un formateur - setIsFormateur(true); - setEmailConfirmed(true); - // Pour les formateurs, on passe directement à la signature - setShowSignaturePad(true); - } - }; - - const handleValiderWithSignature = (signatureDataUrl: string) => { - if (isFormateur) { - // Pour les formateurs : validation directe avec le token - validerPresence({ - token: token || "", - email, - periode: selectedPeriode, - signatureDataUrl, - }); - } else { - // Pour les apprenants : validation classique - if (!selectedDateId) { - toast.error("Veuillez sélectionner une date"); - return; - } - - validerPresence({ - token: token || "", - dateId: selectedDateId, - periode: selectedPeriode, - email, - signatureDataUrl, - }); - } + refetchApprenant(); }; const handleValider = () => { - if (isFormateur) { - // Pour les formateurs, on affiche le pad de signature - setShowSignaturePad(true); - } else { - // Pour les apprenants, vérifier les champs requis - if (!selectedDateId) { - toast.error("Veuillez sélectionner une date"); - return; - } - setShowSignaturePad(true); + if (!selectedInscriptionId || !selectedDateId) { + toast.error("Veuillez sélectionner une date de formation"); + return; } + + // Afficher le pad de signature au lieu de valider directement + setShowSignaturePad(true); }; - if (!token) { - return ( -
- - - - - Lien invalide - - Le lien d'émargement est invalide ou a expiré. - - -
- ); - } + const handleValiderWithSignature = (dataUrl: string) => { + if (!selectedInscriptionId || !selectedDateId) { + toast.error("Veuillez sélectionner une date de formation"); + return; + } - if (showSignaturePad) { - return ( -
- - - Signature de présence - - {isFormateur - ? "Signez pour valider votre présence en tant que formateur" - : "Signez pour valider votre présence"} - - - - setShowSignaturePad(false)} - /> - - -
- ); - } + validerPresence({ + token, + inscriptionId: selectedInscriptionId, + dateFormationId: selectedDateId, + periode: selectedPeriode, + modeValidation: "qrcode", + signatureDataUrl: dataUrl, + }); + }; + + // Déterminer les dates disponibles + const datesDisponibles = (selectedInscription?.sequence as any)?.dates || []; if (success) { return ( -
- - - - - Présence validée +
+ + +
+ +
+ + Présence validée ! - + Votre présence a été enregistrée avec succès - {heureEnregistrement && ( - - Heure d'enregistrement : {heureEnregistrement.toLocaleTimeString("fr-FR")} - - )}
+ + {heureEnregistrement && ( +
+

+ 🕒 Heure d'enregistrement +

+

+ {heureEnregistrement.toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} +

+

+ {heureEnregistrement.toLocaleDateString("fr-FR", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+ )} +

+ Merci d'avoir confirmé votre présence. Vous pouvez maintenant fermer cette page. +

+ +
); @@ -192,17 +166,24 @@ export default function EmargementScan() { if (error) { return ( -
- - - - - Erreur - - {error} +
+ + +
+ +
+ Erreur + {error}
- - @@ -213,20 +194,23 @@ export default function EmargementScan() { return (
- + - Émargement numérique - Validez votre présence en renseignant vos informations + Émargement numérique + + Validez votre présence en renseignant vos informations + - - {!emailConfirmed ? ( -
-
- + + {/* Étape 1: Recherche par email */} + {!apprenant && ( +
+
+ setEmail(e.target.value)} onKeyDown={(e) => { @@ -240,140 +224,132 @@ export default function EmargementScan() { Continuer
- ) : isFormateur ? ( -
-
-
- 👨‍🏫 - Mode formateur -
-

{email}

-
+ )} -
- - -
- - -
- ) : ( + {/* Étape 2: Sélection de l'inscription et de la date */} + {apprenant && !success && (
-
-

{apprenant?.nom} {apprenant?.prenom}

-

{email}

+
+

+ {apprenant.prenom} {apprenant.nom} +

+

{apprenant.email}

{inscriptions && inscriptions.length > 0 ? ( <> -
- +
+
- {selectedInscription && ( -
- - -
- )} + {selectedInscriptionId && datesDisponibles.length > 0 && ( + <> +
+ + +
-
- - -
+
+ + +
+ + )} ) : ( -
-

Aucune inscription trouvée pour cet email.

+
+

+ Aucune inscription trouvée pour cet email +

+
)}
)} + + {showSignaturePad && ( + setShowSignaturePad(false)} + /> + )}
); } diff --git a/client/src/pages/FormateurEmargement.tsx b/client/src/pages/FormateurEmargement.tsx index d341359..0b54af9 100644 --- a/client/src/pages/FormateurEmargement.tsx +++ b/client/src/pages/FormateurEmargement.tsx @@ -26,14 +26,13 @@ export default function FormateurEmargement() { const { user } = useAuth(); const [selectedSequenceId, setSelectedSequenceId] = useState(null); const [showQRDialog, setShowQRDialog] = useState(false); - const [showQRFormateurDialog, setShowQRFormateurDialog] = useState(false); const [lastRefreshTime, setLastRefreshTime] = useState(new Date()); const [secondsSinceRefresh, setSecondsSinceRefresh] = useState(0); // Récupérer toutes les séquences (filtrées côté serveur pour les formateurs) const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery(); - // Générer le QR code apprenants + // Générer le QR code const { data: qrData, mutate: generateQR, isPending: generatingQR } = trpc.presences.generateQRCode.useMutation({ onSuccess: () => { setShowQRDialog(true); @@ -45,18 +44,6 @@ export default function FormateurEmargement() { }, }); - // Générer le QR code formateur - const { data: qrFormateurData, mutate: generateQRFormateur, isPending: generatingQRFormateur } = trpc.presences.generateQRCodeFormateur.useMutation({ - onSuccess: () => { - setShowQRFormateurDialog(true); - }, - onError: (error) => { - toast.error("Erreur lors de la génération du QR code formateur", { - description: error.message, - }); - }, - }); - // Récupérer les inscrits avec les dates de formation const { data: inscrits, isLoading: loadingInscrits, refetch: refetchInscrits } = trpc.inscriptions.listWithDates.useQuery( { sequenceId: selectedSequenceId || 0 }, @@ -134,15 +121,6 @@ export default function FormateurEmargement() { generateQR({ sequenceId: selectedSequenceId }); }; - const handleGenerateQRFormateur = () => { - if (!selectedSequenceId) { - toast.error("Veuillez sélectionner une séquence"); - return; - } - // Générer le QR code formateur - generateQRFormateur({ sequenceId: selectedSequenceId }); - }; - const handleValiderPresence = (inscriptionId: number, dateFormationId: number, periode: "matin" | "apres_midi") => { validerPresence({ inscriptionId, @@ -231,7 +209,7 @@ export default function FormateurEmargement() { {selectedSequenceId && (
-
+
- @@ -486,13 +451,13 @@ export default function FormateurEmargement() { )} - {/* Dialog QR Code Apprenants */} + {/* Dialog QR Code */} - QR Code d'émargement apprenants + QR Code d'émargement - Les apprenants doivent scanner ce QR code pour valider leur présence avec signature tactile + Les apprenants doivent scanner ce QR code pour valider leur présence {qrData && ( @@ -510,38 +475,6 @@ export default function FormateurEmargement() { - {/* Dialog QR Code Formateur */} - - - - QR Code d'émargement formateur - - Scannez ce QR code avec votre smartphone pour émarger en tant que formateur avec signature tactile - - - {qrFormateurData && ( -
- QR Code Formateur -
-

👨‍🏫 Mode formateur -

-

- Ce QR code vous redirigera vers une page dédiée pour l'émargement formateur. - Vous pourrez signer votre présence (matin/après-midi) directement depuis votre smartphone. -

-
-

- Affichez ce QR code en plein écran pour faciliter le scan -

-
- )} -
-
- {/* Dialog Signature */} diff --git a/deploy-to-vps.sh b/deploy-to-vps.sh deleted file mode 100755 index 92edf47..0000000 --- a/deploy-to-vps.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de déploiement automatisé vers le VPS -# Formation Manager Itinova - Émargement Formateur -# Version: e8410230 -############################################################################### - -set -e - -# Configuration VPS -VPS_HOST="98.71.216.234" -VPS_USER="adminServFormation" -VPS_PASSWORD="admin_Itinova69!##" -VPS_PATH="/var/www/formation-manager-itinova" -ARCHIVE_NAME="formation-manager-update-$(date +%Y%m%d-%H%M%S).tar.gz" - -# Couleurs -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' - -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Déploiement automatisé - Formation Manager Itinova ║" -echo "║ Émargement Formateur par QR Code ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# 1. Créer l'archive -log_info "Création de l'archive de déploiement..." -cd /home/ubuntu/formation-manager-itinova -tar --exclude='node_modules' \ - --exclude='.git' \ - --exclude='dist' \ - --exclude='backups' \ - --exclude='uploads' \ - --exclude='*.log' \ - --exclude='screenshots' \ - -czf "/tmp/${ARCHIVE_NAME}" . - -log_info "Archive créée: /tmp/${ARCHIVE_NAME}" -echo "" - -# 2. Transférer l'archive vers le VPS -log_info "Transfert de l'archive vers le VPS..." -sshpass -p "${VPS_PASSWORD}" scp -o StrictHostKeyChecking=no \ - "/tmp/${ARCHIVE_NAME}" \ - "${VPS_USER}@${VPS_HOST}:/tmp/" - -log_info "Archive transférée avec succès" -echo "" - -# 3. Exécuter le déploiement sur le VPS -log_info "Exécution du déploiement sur le VPS..." - -sshpass -p "${VPS_PASSWORD}" ssh -o StrictHostKeyChecking=no \ - "${VPS_USER}@${VPS_HOST}" << ENDSSH - -set -e - -echo "=========================================" -echo "Déploiement sur le VPS" -echo "=========================================" -echo "" - -# Charger les variables d'environnement -cd ${VPS_PATH} -if [ -f .env ]; then - export \$(grep -v '^#' .env | xargs) -fi - -# 1. Sauvegarde de la base de données -echo "1. Sauvegarde de la base de données..." -mkdir -p backups -BACKUP_FILE="backups/db-backup-\$(date +%Y%m%d-%H%M%S).sql" - -if [ ! -z "\$DATABASE_URL" ]; then - DB_USER=\$(echo \$DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') - DB_PASS=\$(echo \$DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') - DB_HOST=\$(echo \$DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') - DB_PORT=\$(echo \$DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') - DB_NAME=\$(echo \$DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - - mysqldump -h "\$DB_HOST" -P "\$DB_PORT" -u "\$DB_USER" -p"\$DB_PASS" "\$DB_NAME" > "\$BACKUP_FILE" 2>/dev/null || true - echo "✓ Sauvegarde créée: \$BACKUP_FILE" -else - echo "⚠ DATABASE_URL non définie, sauvegarde ignorée" -fi -echo "" - -# 2. Arrêt du serveur -echo "2. Arrêt du serveur PM2..." -pm2 stop formation-manager-itinova 2>/dev/null || echo "Le serveur n'était pas démarré" -echo "✓ Serveur arrêté" -echo "" - -# 3. Sauvegarde de l'ancien code -echo "3. Sauvegarde de l'ancien code..." -if [ -d "${VPS_PATH}.backup" ]; then - rm -rf "${VPS_PATH}.backup" -fi -cp -r ${VPS_PATH} ${VPS_PATH}.backup -echo "✓ Ancien code sauvegardé dans ${VPS_PATH}.backup" -echo "" - -# 4. Extraction de la nouvelle version -echo "4. Extraction de la nouvelle version..." -cd ${VPS_PATH} -tar -xzf /tmp/${ARCHIVE_NAME} -rm -f /tmp/${ARCHIVE_NAME} -echo "✓ Nouvelle version extraite" -echo "" - -# 5. Installation des dépendances -echo "5. Installation des dépendances..." -pnpm install --frozen-lockfile 2>&1 | tail -5 -echo "✓ Dépendances installées" -echo "" - -# 6. Application des migrations -echo "6. Application des migrations de base de données..." -pnpm db:push 2>&1 | tail -10 -echo "✓ Migrations appliquées (typeUtilisateur et formateurId ajoutés)" -echo "" - -# 7. Build de l'application -echo "7. Build de l'application..." -pnpm build 2>&1 | tail -5 -echo "✓ Application compilée" -echo "" - -# 8. Redémarrage du serveur -echo "8. Redémarrage du serveur PM2..." -pm2 restart formation-manager-itinova 2>/dev/null || pm2 start npm --name "formation-manager-itinova" -- start -sleep 3 -echo "✓ Serveur redémarré" -echo "" - -# 9. Vérification du statut -echo "9. Vérification du statut..." -pm2 list | grep formation-manager-itinova -echo "" - -echo "=========================================" -echo "✓ Déploiement terminé avec succès !" -echo "=========================================" -echo "" -echo "Application accessible sur: https://formations.itinova.org" -echo "Logs: pm2 logs formation-manager-itinova" -echo "" - -ENDSSH - -echo "" -log_info "╔════════════════════════════════════════════════════════════════╗" -log_info "║ Déploiement terminé avec succès! ✓ ║" -log_info "╚════════════════════════════════════════════════════════════════╝" -echo "" -log_info "L'émargement formateur par QR code est maintenant actif sur:" -log_info "https://formations.itinova.org" -echo "" -log_info "Fonctionnalités déployées:" -echo " • Détection automatique formateur/apprenant via email" -echo " • Signature tactile pour les formateurs" -echo " • Intégration des signatures formateur dans le PDF" -echo " • QR code accessible dans l'espace formateur" -echo "" - -# Nettoyage -rm -f "/tmp/${ARCHIVE_NAME}" - -exit 0 diff --git a/deployment-package/docs/DEPLOIEMENT-MANUEL.md b/deployment-package/docs/DEPLOIEMENT-MANUEL.md deleted file mode 100644 index 63a7bb4..0000000 --- a/deployment-package/docs/DEPLOIEMENT-MANUEL.md +++ /dev/null @@ -1,120 +0,0 @@ -# Guide de déploiement manuel sur VPS (sans Git) - -Ce guide vous permet de déployer votre application sur un serveur VPS sans utiliser Git. - ---- - -## 📋 Prérequis - -- Accès SSH au serveur VPS -- Client SFTP (FileZilla ou WinSCP) -- Fichiers téléchargés depuis Manus - ---- - -## Étape 1 : Télécharger les fichiers depuis Manus - -1. Dans Manus, onglet "Code" → "Download all files" -2. Extraire le ZIP dans un dossier temporaire -3. Supprimer ces dossiers avant transfert : - - `node_modules/` - - `dist/` ou `build/` - - `.env` - - `backups/` - ---- - -## Étape 2 : Transférer les fichiers via SFTP - -### Avec FileZilla - -1. Télécharger : https://filezilla-project.org -2. Connexion SFTP : - - Hôte : votre-serveur.com - - Port : 22 - - Utilisateur : votre-user - - Mot de passe : votre-password -3. Transférer les fichiers vers `/var/www/formation-manager-itinova/` - ---- - -## Étape 3 : Se connecter en SSH - -```bash -ssh votre-utilisateur@votre-serveur.com -cd /var/www/formation-manager-itinova -``` - ---- - -## Étape 4 : Configuration (première installation) - -```bash -# Installer Node.js 22 -curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Installer pnpm et PM2 -npm install -g pnpm pm2 - -# Créer le fichier .env -nano .env -``` - -Copier vos variables d'environnement, sauvegarder (Ctrl+X, Y, Entrée) - ---- - -## Étape 5 : Installation - -```bash -# Installer les dépendances -pnpm install - -# Appliquer les migrations -pnpm db:push - -# Compiler -pnpm build - -# Démarrer -pm2 start npm --name "formation-manager" -- start -pm2 save -pm2 startup -``` - ---- - -## Étape 6 : Mise à jour (installations suivantes) - -```bash -# 1. Sauvegarder la base de données -mkdir -p backups -mysqldump -h HOST -P PORT -u USER -pPASSWORD DATABASE > backups/backup-$(date +%Y%m%d-%H%M%S).sql - -# 2. Arrêter le serveur -pm2 stop formation-manager - -# 3. Transférer les nouveaux fichiers via SFTP - -# 4. Mettre à jour -pnpm install -pnpm db:push -pnpm build - -# 5. Redémarrer -pm2 restart formation-manager -``` - ---- - -## Vérification - -```bash -pm2 status -pm2 logs formation-manager -``` - ---- - -**Bon déploiement ! 🚀** diff --git a/deployment-package/docs/DEPLOIEMENT-TARGZ.md b/deployment-package/docs/DEPLOIEMENT-TARGZ.md deleted file mode 100644 index 910c872..0000000 --- a/deployment-package/docs/DEPLOIEMENT-TARGZ.md +++ /dev/null @@ -1,106 +0,0 @@ -# Guide de déploiement avec archive .tar.gz - -Ce guide est adapté à votre workflow habituel avec des archives .tar.gz. - ---- - -## 📦 Étape 1 : Créer l'archive .tar.gz - -### Sur votre machine locale (après téléchargement depuis Manus) - -```bash -# Aller dans le dossier parent -cd /d/ProjetsGit - -# Créer l'archive (exclure les dossiers inutiles) -tar -czf formation-manager-itinova.tar.gz \ - --exclude='node_modules' \ - --exclude='dist' \ - --exclude='build' \ - --exclude='.env' \ - --exclude='backups' \ - formation-manager-itinova/ -``` - -**Sur Windows (si tar n'est pas disponible)**, utilisez 7-Zip : -1. Clic droit sur le dossier `formation-manager-itinova` -2. 7-Zip → Ajouter à l'archive -3. Format : tar -4. Niveau de compression : Ultra -5. OK - -Ensuite compresser le .tar en .gz : -1. Clic droit sur `formation-manager-itinova.tar` -2. 7-Zip → Ajouter à l'archive -3. Format : gzip -4. OK - ---- - -## 📤 Étape 2 : Transférer l'archive sur le VPS - -### Avec FileZilla/WinSCP - -1. Se connecter au VPS -2. Naviguer vers `/tmp/` ou `/home/votre-utilisateur/` -3. Transférer `formation-manager-itinova.tar.gz` - -### Avec SCP (ligne de commande) - -```bash -scp formation-manager-itinova.tar.gz votre-utilisateur@votre-serveur.com:/tmp/ -``` - ---- - -## 🚀 Étape 3 : Exécuter le script de mise à jour - -### Se connecter en SSH - -```bash -ssh votre-utilisateur@votre-serveur.com -``` - -### Lancer le script de mise à jour - -```bash -cd /var/www/formation-manager-itinova -./deploy-update-targz.sh /tmp/formation-manager-itinova.tar.gz -``` - -Le script va automatiquement : -- ✅ Sauvegarder la base de données -- ✅ Arrêter le serveur -- ✅ Extraire l'archive -- ✅ Copier les nouveaux fichiers (en préservant .env) -- ✅ Installer les dépendances -- ✅ Appliquer les migrations -- ✅ Recompiler le projet -- ✅ Redémarrer le serveur - ---- - -## 🔍 Vérification - -```bash -# Voir le statut -pm2 status - -# Voir les logs -pm2 logs formation-manager --lines 50 -``` - ---- - -## 📝 Résumé de la procédure - -1. **Télécharger** les fichiers depuis Manus (Code → Download all files) -2. **Créer** l'archive .tar.gz -3. **Transférer** l'archive sur le VPS (dans /tmp/) -4. **Se connecter** en SSH au VPS -5. **Exécuter** `./deploy-update-targz.sh /tmp/formation-manager-itinova.tar.gz` -6. **Vérifier** avec `pm2 status` et `pm2 logs` - ---- - -**C'est tout ! 🚀** diff --git a/deployment-package/docs/GUIDE_CONFIGURATION_ATTESTATIONS.md b/deployment-package/docs/GUIDE_CONFIGURATION_ATTESTATIONS.md deleted file mode 100644 index 2043374..0000000 --- a/deployment-package/docs/GUIDE_CONFIGURATION_ATTESTATIONS.md +++ /dev/null @@ -1,246 +0,0 @@ -# Guide de configuration des attestations de formation - -**Auteur :** Manus AI -**Date :** 13 janvier 2026 -**Version :** 1.0 - ---- - -## Introduction - -Ce guide explique comment configurer et gérer les attestations de formation dans l'application de gestion des formations Manager Itinova. Le système d'attestations permet de générer et d'envoyer automatiquement ou manuellement des documents officiels aux apprenants ayant complété une formation. - -La configuration des attestations se fait désormais de manière centralisée dans la page **"Gestion des attestations"**, accessible depuis le menu de navigation principal. - ---- - -## Accès à la page de gestion - -Pour accéder à la configuration des attestations, suivez ces étapes : - -1. Connectez-vous à l'application avec un compte administrateur -2. Dans le menu latéral gauche, cliquez sur **"Gestion des attestations"** (section "Gestion") -3. Vous accédez alors à l'interface de configuration - ---- - -## Configuration par formation - -La configuration des attestations se fait **formation par formation**. Cela permet d'adapter le mode de génération et d'envoi selon les besoins spécifiques de chaque type de formation. - -### Étape 1 : Sélectionner une formation - -Dans la section **"Sélectionner une formation"**, utilisez le menu déroulant pour choisir la formation que vous souhaitez configurer. La liste affiche toutes les formations actives dans le système. - -### Étape 2 : Configurer le mode de génération - -Le système propose deux modes de génération des attestations : - -| Mode | Description | Cas d'usage | -|------|-------------|-------------| -| **Génération automatique** | Les attestations sont générées automatiquement à partir du modèle configuré dans le système | Formations standardisées avec un format d'attestation uniforme | -| **Import manuel** | Vous uploadez un document PDF personnalisé pour chaque apprenant | Formations nécessitant des attestations personnalisées ou avec des mentions spécifiques | - -**Pour sélectionner le mode :** - -Cochez l'option correspondante dans la section "Mode de génération des attestations". Le mode automatique est recommandé pour la plupart des formations, car il garantit une cohérence dans les documents délivrés et réduit considérablement le temps de traitement. - -### Étape 3 : Configurer le mode d'envoi - -Le système propose deux modes d'envoi des attestations : - -| Mode | Description | Cas d'usage | -|------|-------------|-------------| -| **Envoi automatique** | Les attestations sont envoyées automatiquement par email aux apprenants dès qu'elles sont générées ou uploadées | Formations à grande échelle où l'envoi manuel serait chronophage | -| **Envoi manuel** | Vous décidez quand envoyer chaque attestation via un bouton d'action | Formations nécessitant une validation manuelle avant envoi, ou pour un contrôle précis du timing d'envoi | - -**Pour sélectionner le mode :** - -Cochez l'option correspondante dans la section "Mode d'envoi des attestations". L'envoi manuel offre plus de contrôle, tandis que l'envoi automatique optimise le processus pour les formations récurrentes. - -### Étape 4 : Enregistrer la configuration - -Une fois les deux modes sélectionnés, cliquez sur le bouton **"Enregistrer la configuration"** pour appliquer les paramètres. Un message de confirmation s'affichera pour indiquer que la configuration a été enregistrée avec succès. - ---- - -## Gestion des apprenants et attestations - -Après avoir configuré une formation, la section **"Apprenants inscrits"** affiche la liste de tous les apprenants ayant participé à cette formation, avec leur statut d'attestation. - -### Tableau des apprenants - -Le tableau affiche les informations suivantes pour chaque apprenant : - -- **Nom et prénom** : Identité de l'apprenant -- **Email** : Adresse email de contact -- **Séquence** : Nom de la séquence de formation suivie -- **Statut** : État de l'attestation (Générée, En attente, Envoyée, etc.) -- **Actions disponibles** : Boutons pour prévisualiser, envoyer ou supprimer l'attestation - -### Actions disponibles - -Selon le mode de génération et d'envoi configuré, différentes actions sont disponibles : - -#### Mode génération automatique - -- **Prévisualiser** (icône œil) : Affiche l'attestation générée dans une fenêtre modale -- **Envoyer** (icône enveloppe) : Envoie l'attestation par email à l'apprenant (si mode d'envoi manuel) -- **Supprimer** (icône corbeille) : Supprime l'attestation générée (nécessite une confirmation) - -#### Mode import manuel - -- **Upload** (icône téléchargement) : Permet d'uploader un fichier PDF personnalisé pour cet apprenant -- **Prévisualiser** (icône œil) : Affiche l'attestation uploadée -- **Envoyer** (icône enveloppe) : Envoie l'attestation par email (si mode d'envoi manuel) -- **Supprimer** (icône corbeille) : Supprime le document uploadé - ---- - -## Workflows recommandés - -### Workflow 1 : Formation standardisée avec envoi automatique - -**Configuration recommandée :** -- Mode de génération : **Automatique** -- Mode d'envoi : **Automatique** - -**Processus :** - -1. Configurez la formation avec les deux modes automatiques -2. Enregistrez la configuration -3. À la fin de chaque séquence, les attestations sont automatiquement générées et envoyées aux apprenants -4. Consultez le tableau pour vérifier que tous les apprenants ont bien reçu leur attestation - -**Avantages :** Processus entièrement automatisé, gain de temps considérable, aucune intervention manuelle nécessaire. - -### Workflow 2 : Formation avec validation manuelle - -**Configuration recommandée :** -- Mode de génération : **Automatique** -- Mode d'envoi : **Manuel** - -**Processus :** - -1. Configurez la formation avec génération automatique et envoi manuel -2. Enregistrez la configuration -3. À la fin de chaque séquence, les attestations sont automatiquement générées -4. Consultez le tableau des apprenants -5. Prévisualisez chaque attestation pour vérifier son contenu -6. Cliquez sur le bouton "Envoyer" pour chaque apprenant validé - -**Avantages :** Contrôle total sur l'envoi, possibilité de vérifier chaque attestation avant envoi, flexibilité dans le timing. - -### Workflow 3 : Formation avec attestations personnalisées - -**Configuration recommandée :** -- Mode de génération : **Manuel** -- Mode d'envoi : **Manuel** - -**Processus :** - -1. Configurez la formation avec les deux modes manuels -2. Enregistrez la configuration -3. Préparez les attestations personnalisées en PDF pour chaque apprenant -4. Dans le tableau, cliquez sur le bouton "Upload" pour chaque apprenant -5. Sélectionnez le fichier PDF correspondant -6. Prévisualisez l'attestation uploadée pour vérifier -7. Cliquez sur "Envoyer" pour transmettre l'attestation par email - -**Avantages :** Personnalisation maximale, adaptation aux besoins spécifiques de chaque apprenant, contrôle total du processus. - ---- - -## Bonnes pratiques - -### Nommage des fichiers PDF - -Lorsque vous uploadez des attestations manuellement, utilisez une convention de nommage claire pour faciliter l'identification : - -``` -Attestation_[NomFormation]_[NomApprenant]_[Date].pdf -``` - -**Exemple :** `Attestation_ManagementEquipe_Dupont_20260113.pdf` - -### Vérification avant envoi - -Même en mode automatique, il est recommandé de vérifier périodiquement le tableau des apprenants pour s'assurer que tous les envois ont été effectués avec succès. Les erreurs d'envoi (adresse email invalide, problème de configuration SMTP) sont signalées dans la colonne "Statut". - -### Archivage - -Le système conserve automatiquement un historique de toutes les attestations générées et envoyées. Cet historique est accessible depuis la page "Historique des attestations" et permet de : - -- Consulter les attestations déjà envoyées -- Renvoyer une attestation en cas de perte par l'apprenant -- Générer des rapports sur les attestations délivrées - -### Sauvegarde des documents - -Bien que le système stocke les attestations de manière sécurisée, il est recommandé de maintenir une sauvegarde locale des documents importants, notamment pour les formations certifiantes ou réglementées. - ---- - -## Dépannage - -### L'attestation n'a pas été envoyée - -**Causes possibles :** - -- Configuration SMTP incorrecte ou incomplète -- Adresse email de l'apprenant invalide -- Problème de connexion réseau temporaire - -**Solution :** - -1. Vérifiez la configuration SMTP dans la page "Configuration Email" -2. Vérifiez l'adresse email de l'apprenant dans sa fiche -3. Utilisez le bouton "Envoyer" pour retenter l'envoi manuel - -### L'attestation générée automatiquement est incorrecte - -**Causes possibles :** - -- Modèle d'attestation mal configuré -- Données de l'apprenant incomplètes - -**Solution :** - -1. Vérifiez les informations de l'apprenant (nom, prénom, dates de formation) -2. Contactez l'administrateur système pour vérifier le modèle d'attestation -3. En attendant la correction, passez en mode "Import manuel" pour cette formation - -### Impossible d'uploader un fichier PDF - -**Causes possibles :** - -- Fichier trop volumineux (limite : 10 Mo) -- Format de fichier incorrect (seul le PDF est accepté) -- Problème de connexion réseau - -**Solution :** - -1. Vérifiez que le fichier est bien au format PDF -2. Compressez le fichier PDF si sa taille dépasse 10 Mo -3. Réessayez l'upload après avoir vérifié votre connexion internet - ---- - -## Support et assistance - -Pour toute question ou problème technique concernant la configuration des attestations, contactez l'équipe support à l'adresse suivante : - -**Email :** support@itinova.org -**Téléphone :** +33 (0)1 XX XX XX XX - ---- - -## Historique des versions - -| Version | Date | Modifications | -|---------|------|---------------| -| 1.0 | 13 janvier 2026 | Création du guide initial | - ---- - -**© 2026 Manager Itinova - Tous droits réservés** diff --git a/deployment-package/docs/GUIDE_DEPLOIEMENT.md b/deployment-package/docs/GUIDE_DEPLOIEMENT.md deleted file mode 100644 index df41dd5..0000000 --- a/deployment-package/docs/GUIDE_DEPLOIEMENT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Guide de Déploiement - Formation Manager Itinova - -**Version:** 7a737c05 -**Date:** 24 novembre 2025 -**Auteur:** Manus AI - ---- - -## Vue d'ensemble - -Ce document fournit les instructions complètes pour déployer l'application **Formation Manager Itinova** sur un serveur de production. L'application est construite avec React 19, Express 4, tRPC 11 et utilise une base de données MySQL/TiDB. - ---- - -## Prérequis système - -Avant de commencer le déploiement, assurez-vous que votre serveur dispose des éléments suivants : - -### Configuration matérielle minimale - -| Composant | Spécification minimale | Recommandé | -|-----------|------------------------|------------| -| CPU | 2 cœurs | 4 cœurs | -| RAM | 2 GB | 4 GB | -| Stockage | 20 GB | 50 GB SSD | -| Bande passante | 100 Mbps | 1 Gbps | - -### Logiciels requis - -L'application nécessite les logiciels suivants installés sur le serveur : - -**Node.js version 22.13.0 ou supérieure** : Le runtime JavaScript est essentiel pour exécuter l'application côté serveur. La version 22.13.0 garantit la compatibilité avec toutes les dépendances du projet. - -**pnpm** : Le gestionnaire de paquets pnpm est utilisé pour installer les dépendances de manière efficace. Il offre de meilleures performances et une gestion optimisée de l'espace disque par rapport à npm. - -**MySQL 8.0 ou TiDB** : La base de données relationnelle stocke toutes les données de l'application (formations, séquences, apprenants, inscriptions). TiDB est compatible MySQL et offre une scalabilité horizontale pour les déploiements à grande échelle. - -**Nginx ou Apache** : Un serveur web reverse proxy est recommandé pour gérer le SSL/TLS, la compression et le cache statique. Nginx est préféré pour ses performances supérieures. - -**Systemd** : Le gestionnaire de services Linux permet de gérer l'application comme un service système, assurant le démarrage automatique et la supervision. - -**Git** : Le système de contrôle de version est nécessaire pour cloner le dépôt et effectuer les mises à jour. - ---- - -## Architecture de déploiement - -L'application suit une architecture client-serveur moderne avec les composants suivants : - -### Composants principaux - -**Frontend React** : L'interface utilisateur est construite avec React 19 et Tailwind CSS 4. Le build de production génère des fichiers statiques optimisés (HTML, CSS, JavaScript) qui sont servis par le serveur Express. - -**Backend Express + tRPC** : Le serveur Node.js gère les requêtes API via tRPC, offrant une communication type-safe entre le client et le serveur. Toutes les routes API sont préfixées par `/api/`. - -**Base de données MySQL/TiDB** : La couche de persistance utilise Drizzle ORM pour interagir avec la base de données. Le schéma est défini dans `drizzle/schema.ts` et les migrations sont gérées automatiquement. - -**Authentification OAuth** : Le système d'authentification utilise Manus OAuth pour la gestion des utilisateurs. Les sessions sont stockées dans des cookies HTTP-only sécurisés. - -### Flux de requêtes - -Les requêtes utilisateur suivent ce chemin : **Navigateur → Nginx (SSL/TLS) → Express (port 3000) → tRPC → Base de données**. Les fichiers statiques sont servis directement par Express depuis le répertoire `dist/public/`. - ---- - -## Étape 1 : Préparation du serveur - -### Installation de Node.js - -La première étape consiste à installer Node.js version 22.13.0 sur votre serveur Ubuntu. Utilisez les commandes suivantes pour installer Node.js via le gestionnaire de versions nvm : - -```bash -# Installer nvm (Node Version Manager) -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash - -# Recharger le profil shell -source ~/.bashrc - -# Installer Node.js 22.13.0 -nvm install 22.13.0 - -# Définir comme version par défaut -nvm use 22.13.0 -nvm alias default 22.13.0 - -# Vérifier l'installation -node --version # Doit afficher v22.13.0 -``` - -### Installation de pnpm - -Une fois Node.js installé, installez pnpm globalement : - -```bash -npm install -g pnpm - -# Vérifier l'installation -pnpm --version -``` - -### Configuration de la base de données - -Créez une base de données MySQL dédiée pour l'application. Connectez-vous à MySQL et exécutez les commandes suivantes : - -```sql -CREATE DATABASE formation_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'formation_user'@'localhost' IDENTIFIED BY 'MOT_DE_PASSE_SECURISE'; -GRANT ALL PRIVILEGES ON formation_manager.* TO 'formation_user'@'localhost'; -FLUSH PRIVILEGES; -``` - -Remplacez `MOT_DE_PASSE_SECURISE` par un mot de passe fort généré aléatoirement. Notez les informations de connexion pour la configuration ultérieure. - -### Création de l'utilisateur système - -Pour des raisons de sécurité, créez un utilisateur système dédié pour exécuter l'application : - -```bash -sudo useradd -r -s /bin/bash -d /opt/formation-manager formation-manager -sudo mkdir -p /opt/formation-manager -sudo chown formation-manager:formation-manager /opt/formation-manager -``` - ---- - -## Étape 2 : Clonage et configuration - -### Clonage du dépôt - -Connectez-vous en tant qu'utilisateur `formation-manager` et clonez le dépôt Git : - -```bash -sudo su - formation-manager -cd /opt/formation-manager -git clone app -cd app -git checkout 7a737c05 -``` - -Remplacez `` par l'URL réelle de votre dépôt Git. - -### Configuration des variables d'environnement - -Créez un fichier `.env` à la racine du projet avec les variables suivantes : - -```bash -# Base de données -DATABASE_URL=mysql://formation_user:MOT_DE_PASSE_SECURISE@localhost:3306/formation_manager - -# Authentification -JWT_SECRET= -OAUTH_SERVER_URL=https://api.manus.im -VITE_OAUTH_PORTAL_URL=https://auth.manus.im - -# Identifiants propriétaire -OWNER_OPEN_ID= -OWNER_NAME= - -# Configuration application -VITE_APP_ID= -VITE_APP_TITLE="Gestion des Formations Manager Itinova" -VITE_APP_LOGO=/logo.png - -# APIs Manus (fournis automatiquement par la plateforme) -BUILT_IN_FORGE_API_URL= -BUILT_IN_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_URL= - -# Analytics (optionnel) -VITE_ANALYTICS_ENDPOINT= -VITE_ANALYTICS_WEBSITE_ID= - -# Production -NODE_ENV=production -PORT=3000 -``` - -**Important** : Générez une clé JWT sécurisée avec la commande suivante : - -```bash -openssl rand -hex 32 -``` - -### Installation des dépendances - -Installez toutes les dépendances du projet : - -```bash -pnpm install -``` - -Cette commande télécharge et installe toutes les bibliothèques nécessaires définies dans `package.json`. Le processus peut prendre plusieurs minutes selon votre connexion internet. - ---- - -## Étape 3 : Migration de la base de données - -### Application du schéma - -Appliquez le schéma de base de données en exécutant : - -```bash -pnpm db:push -``` - -Cette commande utilise Drizzle Kit pour créer automatiquement toutes les tables nécessaires dans la base de données. Les tables suivantes seront créées : - -| Table | Description | -|-------|-------------| -| `users` | Utilisateurs et administrateurs | -| `formations` | Formations disponibles | -| `sequences` | Séquences de formation | -| `apprenants` | Apprenants inscrits | -| `inscriptions` | Inscriptions aux séquences | -| `emailTemplates` | Templates d'emails personnalisés | -| `alertes` | Alertes et notifications | - -### Vérification du schéma - -Connectez-vous à MySQL et vérifiez que toutes les tables ont été créées correctement : - -```bash -mysql -u formation_user -p formation_manager -``` - -```sql -SHOW TABLES; -DESCRIBE users; -DESCRIBE formations; -``` - ---- - -## Étape 4 : Build de production - -### Compilation de l'application - -Compilez l'application pour la production : - -```bash -pnpm run build -``` - -Cette commande effectue les opérations suivantes : - -1. **Compilation TypeScript** : Transpile le code TypeScript du serveur en JavaScript -2. **Build Vite** : Compile et optimise le frontend React (minification, tree-shaking, code splitting) -3. **Génération des assets** : Crée les fichiers statiques dans `dist/public/` - -Le build de production génère un répertoire `dist/` contenant : - -- `dist/index.js` : Le serveur Express compilé -- `dist/public/` : Les fichiers statiques du frontend (HTML, CSS, JS, images) - -### Vérification du build - -Vérifiez que le build s'est terminé sans erreur et que les fichiers ont été générés : - -```bash -ls -lh dist/ -ls -lh dist/public/ -``` - ---- - -## Étape 5 : Configuration du service systemd - -### Création du fichier service - -Créez un fichier de service systemd pour gérer l'application : - -```bash -sudo nano /etc/systemd/system/formation-manager.service -``` - -Ajoutez le contenu suivant : - -```ini -[Unit] -Description=Formation Manager Itinova -After=network.target mysql.service -Wants=mysql.service - -[Service] -Type=simple -User=formation-manager -Group=formation-manager -WorkingDirectory=/opt/formation-manager/app -Environment="NODE_ENV=production" -Environment="PORT=3000" -EnvironmentFile=/opt/formation-manager/app/.env -ExecStart=/home/formation-manager/.nvm/versions/node/v22.13.0/bin/node dist/index.js -Restart=always -RestartSec=10 -StandardOutput=journal -StandardError=journal -SyslogIdentifier=formation-manager - -# Limites de sécurité -LimitNOFILE=65536 -PrivateTmp=true -NoNewPrivileges=true - -[Install] -WantedBy=multi-user.target -``` - -### Activation du service - -Rechargez systemd, activez et démarrez le service : - -```bash -sudo systemctl daemon-reload -sudo systemctl enable formation-manager -sudo systemctl start formation-manager -``` - -### Vérification du statut - -Vérifiez que le service fonctionne correctement : - -```bash -sudo systemctl status formation-manager -``` - -Vous devriez voir `active (running)` en vert. Consultez les logs en cas d'erreur : - -```bash -sudo journalctl -u formation-manager -f -``` - ---- - -## Étape 6 : Configuration Nginx (reverse proxy) - -### Installation de Nginx - -Si Nginx n'est pas déjà installé : - -```bash -sudo apt update -sudo apt install nginx -``` - -### Configuration du site - -Créez un fichier de configuration pour votre site : - -```bash -sudo nano /etc/nginx/sites-available/formation-manager -``` - -Ajoutez la configuration suivante : - -```nginx -upstream formation_backend { - server 127.0.0.1:3000; - keepalive 64; -} - -server { - listen 80; - server_name votre-domaine.com www.votre-domaine.com; - - # Redirection HTTP vers HTTPS - return 301 https://$server_name$request_uri; -} - -server { - listen 443 ssl http2; - server_name votre-domaine.com www.votre-domaine.com; - - # Certificats SSL (à configurer avec Let's Encrypt) - ssl_certificate /etc/letsencrypt/live/votre-domaine.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/votre-domaine.com/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - ssl_prefer_server_ciphers on; - - # Logs - access_log /var/log/nginx/formation-manager-access.log; - error_log /var/log/nginx/formation-manager-error.log; - - # Taille maximale des uploads - client_max_body_size 10M; - - # Compression - gzip on; - gzip_vary on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Proxy vers Node.js - location / { - proxy_pass http://formation_backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # Cache des assets statiques - location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { - proxy_pass http://formation_backend; - expires 1y; - add_header Cache-Control "public, immutable"; - } -} -``` - -Remplacez `votre-domaine.com` par votre nom de domaine réel. - -### Activation du site - -Activez la configuration et redémarrez Nginx : - -```bash -sudo ln -s /etc/nginx/sites-available/formation-manager /etc/nginx/sites-enabled/ -sudo nginx -t # Tester la configuration -sudo systemctl restart nginx -``` - -### Configuration SSL avec Let's Encrypt - -Installez Certbot et obtenez un certificat SSL gratuit : - -```bash -sudo apt install certbot python3-certbot-nginx -sudo certbot --nginx -d votre-domaine.com -d www.votre-domaine.com -``` - -Suivez les instructions à l'écran. Certbot configurera automatiquement Nginx pour utiliser HTTPS. - ---- - -## Étape 7 : Configuration du pare-feu - -### Configuration UFW - -Si vous utilisez UFW (Uncomplicated Firewall), autorisez les ports nécessaires : - -```bash -sudo ufw allow 22/tcp # SSH -sudo ufw allow 80/tcp # HTTP -sudo ufw allow 443/tcp # HTTPS -sudo ufw enable -sudo ufw status -``` - -### Sécurisation SSH (recommandé) - -Désactivez l'authentification par mot de passe SSH et utilisez uniquement les clés : - -```bash -sudo nano /etc/ssh/sshd_config -``` - -Modifiez les lignes suivantes : - -``` -PasswordAuthentication no -PermitRootLogin no -``` - -Redémarrez SSH : - -```bash -sudo systemctl restart sshd -``` - ---- - -## Étape 8 : Vérification du déploiement - -### Tests fonctionnels - -Accédez à votre application via le navigateur à l'adresse `https://votre-domaine.com` et vérifiez les éléments suivants : - -| Test | Description | Résultat attendu | -|------|-------------|------------------| -| Page d'accueil | Chargement de la page d'accueil | Affichage correct sans erreur | -| Connexion | Authentification via Manus OAuth | Redirection vers le tableau de bord | -| Tableau de bord | Affichage des statistiques | Graphiques et cartes visibles | -| Formations | Liste des formations | Tableau avec données | -| Séquences | Liste des séquences | Tableau avec code couleur | -| Apprenants | Liste des apprenants | Tableau avec badges de statut | -| Templates d'emails | Modification d'un template | Éditeur TipTap fonctionnel | - -### Vérification des logs - -Surveillez les logs pour détecter d'éventuelles erreurs : - -```bash -# Logs de l'application -sudo journalctl -u formation-manager -f - -# Logs Nginx -sudo tail -f /var/log/nginx/formation-manager-error.log - -# Logs système -sudo tail -f /var/log/syslog -``` - -### Tests de performance - -Utilisez des outils pour tester les performances de votre application : - -```bash -# Test de charge avec Apache Bench -ab -n 1000 -c 10 https://votre-domaine.com/ - -# Analyse des temps de réponse -curl -w "@curl-format.txt" -o /dev/null -s https://votre-domaine.com/ -``` - ---- - -## Maintenance et mises à jour - -### Sauvegarde de la base de données - -Créez un script de sauvegarde automatique : - -```bash -sudo nano /opt/formation-manager/backup-db.sh -``` - -Contenu du script : - -```bash -#!/bin/bash -DATE=$(date +%Y%m%d_%H%M%S) -BACKUP_DIR="/opt/formation-manager/backups" -mkdir -p $BACKUP_DIR - -mysqldump -u formation_user -p'MOT_DE_PASSE' formation_manager | gzip > $BACKUP_DIR/formation_manager_$DATE.sql.gz - -# Garder seulement les 30 dernières sauvegardes -find $BACKUP_DIR -name "formation_manager_*.sql.gz" -mtime +30 -delete -``` - -Rendez le script exécutable et ajoutez-le au cron : - -```bash -chmod +x /opt/formation-manager/backup-db.sh -sudo crontab -e -``` - -Ajoutez la ligne suivante pour une sauvegarde quotidienne à 2h du matin : - -``` -0 2 * * * /opt/formation-manager/backup-db.sh -``` - -### Mise à jour de l'application - -Pour mettre à jour l'application vers une nouvelle version : - -```bash -cd /opt/formation-manager/app -sudo systemctl stop formation-manager -git pull origin main -pnpm install -pnpm db:push # Appliquer les migrations -pnpm run build -sudo systemctl start formation-manager -``` - -### Surveillance et monitoring - -Installez des outils de monitoring pour surveiller les performances : - -**PM2** : Alternative à systemd avec monitoring intégré -**Prometheus + Grafana** : Monitoring avancé et dashboards -**Uptime Kuma** : Surveillance de disponibilité -**New Relic / DataDog** : Solutions SaaS complètes - ---- - -## Dépannage - -### L'application ne démarre pas - -Vérifiez les logs pour identifier l'erreur : - -```bash -sudo journalctl -u formation-manager -n 100 -``` - -Causes courantes : - -- **Erreur de connexion à la base de données** : Vérifiez `DATABASE_URL` dans `.env` -- **Port déjà utilisé** : Changez le port dans `.env` ou arrêtez le processus conflictuel -- **Permissions insuffisantes** : Vérifiez les droits sur `/opt/formation-manager/app` - -### Erreur 502 Bad Gateway - -Cette erreur indique que Nginx ne peut pas se connecter au backend Node.js. Vérifications : - -```bash -# Vérifier que le service tourne -sudo systemctl status formation-manager - -# Vérifier que le port 3000 écoute -sudo netstat -tulpn | grep 3000 - -# Tester la connexion locale -curl http://localhost:3000 -``` - -### Problèmes de performance - -Si l'application est lente : - -1. **Optimiser les requêtes SQL** : Ajoutez des index sur les colonnes fréquemment interrogées -2. **Activer le cache Redis** : Mettez en cache les résultats des requêtes coûteuses -3. **Augmenter les ressources** : Ajoutez plus de RAM ou de CPU -4. **Activer la compression Nginx** : Déjà configurée dans l'exemple ci-dessus - -### Base de données corrompue - -En cas de corruption de la base de données, restaurez depuis une sauvegarde : - -```bash -gunzip < /opt/formation-manager/backups/formation_manager_YYYYMMDD_HHMMSS.sql.gz | mysql -u formation_user -p formation_manager -``` - ---- - -## Sécurité - -### Bonnes pratiques - -Suivez ces recommandations pour sécuriser votre déploiement : - -**Mettez à jour régulièrement** : Appliquez les mises à jour de sécurité du système d'exploitation et des dépendances Node.js. - -**Utilisez HTTPS uniquement** : Forcez la redirection HTTP vers HTTPS et activez HSTS (HTTP Strict Transport Security). - -**Limitez les accès SSH** : Utilisez des clés SSH au lieu de mots de passe et limitez l'accès par IP si possible. - -**Configurez un WAF** : Utilisez un Web Application Firewall comme ModSecurity ou Cloudflare pour bloquer les attaques courantes. - -**Surveillez les logs** : Configurez des alertes pour détecter les tentatives d'intrusion ou les comportements anormaux. - -**Sauvegardez régulièrement** : Automatisez les sauvegardes de la base de données et testez régulièrement la restauration. - -### Checklist de sécurité - -Avant de mettre en production, vérifiez les points suivants : - -- [ ] Certificat SSL valide configuré -- [ ] Pare-feu activé et configuré -- [ ] Authentification SSH par clé uniquement -- [ ] Variables d'environnement sécurisées (pas de valeurs par défaut) -- [ ] Sauvegardes automatiques configurées -- [ ] Monitoring et alertes en place -- [ ] Logs rotatifs configurés -- [ ] Permissions fichiers correctes (pas de 777) -- [ ] Base de données accessible uniquement en local -- [ ] Rate limiting configuré sur Nginx - ---- - -## Support et contact - -Pour toute question ou problème concernant le déploiement : - -- **Documentation technique** : Consultez le README.md du projet -- **Support Manus** : https://help.manus.im -- **Contact développeur** : o.pareige@itinova.org - ---- - -**Fin du guide de déploiement** diff --git a/deployment-package/docs/INSTRUCTIONS-DEPLOIEMENT.md b/deployment-package/docs/INSTRUCTIONS-DEPLOIEMENT.md deleted file mode 100644 index f4ee331..0000000 --- a/deployment-package/docs/INSTRUCTIONS-DEPLOIEMENT.md +++ /dev/null @@ -1,234 +0,0 @@ -# Instructions de déploiement - Formation Manager Itinova - -Version: **d0d9d870** -Date: 3 janvier 2025 - -## 📋 Résumé des modifications - -Cette mise à jour contient les corrections suivantes : - -1. **Bug d'inscription - Mauvais apprenant associé** - - Création d'une procédure publique `apprenants.createPublic` - - Les inscriptions depuis la page publique fonctionnent correctement - -2. **Bug email de confirmation - Variables non remplacées** - - Correction du template d'inscription dans la base de données - - Ajout de la variable `{{datesHTML}}` dans l'objet variables - - Toutes les variables sont maintenant correctement remplacées - -3. **Amélioration de l'interface des templates d'emails** - - Ajout du bouton `{{datesHTML}}` dans l'éditeur - - Prévisualisation améliorée avec exemple de dates formatées - ---- - -## 🚀 Déploiement automatique (recommandé) - -### Prérequis -- Accès SSH au serveur VPS -- Git configuré avec accès au dépôt -- PM2 installé pour la gestion du processus -- pnpm installé - -### Étapes - -1. **Se connecter au serveur VPS** - ```bash - ssh votre-utilisateur@votre-serveur.com - ``` - -2. **Aller dans le répertoire du projet** - ```bash - cd /chemin/vers/formation-manager-itinova - ``` - -3. **Rendre le script exécutable** - ```bash - chmod +x deploy-update.sh - ``` - -4. **Exécuter le script de mise à jour** - ```bash - ./deploy-update.sh - ``` - -Le script va automatiquement : -- ✅ Sauvegarder la base de données -- ✅ Arrêter le serveur -- ✅ Récupérer les dernières modifications -- ✅ Installer les dépendances -- ✅ Appliquer les migrations -- ✅ Compiler le projet -- ✅ Redémarrer le serveur - ---- - -## 🔧 Déploiement manuel - -Si vous préférez effectuer la mise à jour manuellement : - -### 1. Sauvegarde de la base de données - -```bash -# Créer un répertoire de backup -mkdir -p backups - -# Sauvegarder la base de données -mysqldump -h VOTRE_HOST -u VOTRE_USER -p VOTRE_DATABASE > backups/db-backup-$(date +%Y%m%d-%H%M%S).sql -``` - -### 2. Arrêter le serveur - -```bash -pm2 stop formation-manager -``` - -### 3. Récupérer les modifications - -```bash -git fetch origin -git checkout main -git pull origin main -``` - -### 4. Installer les dépendances - -```bash -pnpm install -``` - -### 5. Appliquer les migrations de base de données - -```bash -pnpm db:push -``` - -**OU** appliquer manuellement le script SQL : - -```bash -mysql -h VOTRE_HOST -u VOTRE_USER -p VOTRE_DATABASE < update-database.sql -``` - -### 6. Compiler le projet - -```bash -pnpm build -``` - -### 7. Redémarrer le serveur - -```bash -pm2 restart formation-manager -# OU si c'est le premier démarrage : -pm2 start npm --name "formation-manager" -- start -``` - -### 8. Vérifier le statut - -```bash -pm2 status formation-manager -pm2 logs formation-manager --lines 50 -``` - ---- - -## 🧪 Tests après déploiement - -Après le déploiement, vérifiez que tout fonctionne correctement : - -### 1. Test d'inscription publique -- [ ] Aller sur la page d'inscription publique d'une formation -- [ ] Créer une nouvelle inscription avec un nouvel apprenant -- [ ] Vérifier que l'apprenant est correctement créé dans la base de données -- [ ] Vérifier que l'inscription est associée au bon apprenant - -### 2. Test de l'email de confirmation -- [ ] Effectuer une inscription -- [ ] Vérifier la réception de l'email de confirmation -- [ ] Vérifier que toutes les variables sont remplacées (notamment `{{prenomApprenant}}` et `{{datesHTML}}`) -- [ ] Vérifier que les invitations ICS sont jointes (une par date) - -### 3. Test de l'interface des templates -- [ ] Aller dans Configuration > Templates d'emails -- [ ] Vérifier que le bouton "Dates (HTML)" est présent -- [ ] Cliquer sur le bouton pour insérer `{{datesHTML}}` -- [ ] Vérifier la prévisualisation - ---- - -## 📊 Vérification de la base de données - -Pour vérifier que la mise à jour du template d'inscription a été appliquée : - -```sql -SELECT - type, - name, - bodyContent, - updatedAt -FROM emailTemplates -WHERE type = 'inscription'; -``` - -Le `bodyContent` doit contenir les variables `{{prenomApprenant}}`, `{{nomApprenant}}`, et `{{datesHTML}}`. - ---- - -## 🔄 Rollback (en cas de problème) - -Si vous rencontrez des problèmes après la mise à jour : - -### 1. Restaurer la base de données - -```bash -# Lister les sauvegardes disponibles -ls -lh backups/ - -# Restaurer une sauvegarde -mysql -h VOTRE_HOST -u VOTRE_USER -p VOTRE_DATABASE < backups/db-backup-YYYYMMDD-HHMMSS.sql -``` - -### 2. Revenir à la version précédente du code - -```bash -# Voir l'historique des commits -git log --oneline - -# Revenir au commit précédent -git checkout COMMIT_HASH - -# Réinstaller les dépendances -pnpm install - -# Recompiler -pnpm build - -# Redémarrer -pm2 restart formation-manager -``` - ---- - -## 📞 Support - -En cas de problème lors du déploiement : - -1. Consulter les logs du serveur : `pm2 logs formation-manager` -2. Vérifier l'état du serveur : `pm2 status` -3. Vérifier les logs de la base de données -4. Contacter le support technique - ---- - -## 📝 Notes importantes - -- **Sauvegarde automatique** : Le script `deploy-update.sh` crée automatiquement une sauvegarde de la base de données avant toute modification -- **Temps d'arrêt** : Le déploiement nécessite un arrêt temporaire du serveur (environ 2-3 minutes) -- **Variables d'environnement** : Assurez-vous que toutes les variables d'environnement nécessaires sont correctement configurées (DATABASE_URL, RESEND_API_KEY, etc.) -- **Permissions** : Le script doit être exécuté avec les permissions appropriées pour accéder à la base de données et redémarrer PM2 - ---- - -**Version du checkpoint** : d0d9d870 -**Date de création** : 3 janvier 2025 -**Auteur** : Manus AI diff --git a/deployment-package/docs/README.md b/deployment-package/docs/README.md deleted file mode 100644 index 3e60904..0000000 --- a/deployment-package/docs/README.md +++ /dev/null @@ -1,455 +0,0 @@ -# Gestion des Formations Manager Itinova - -Application web complète de gestion des formations pour Manager Itinova, permettant la planification, l'organisation et le suivi des formations avec gestion automatisée des inscriptions, rappels et communications. - -![Version](https://img.shields.io/badge/version-1.0.0-blue.svg) -![Node](https://img.shields.io/badge/node-22.13.0-green.svg) -![License](https://img.shields.io/badge/license-Proprietary-red.svg) - ---- - -## 📋 Table des matières - -- [Aperçu](#aperçu) -- [Fonctionnalités](#fonctionnalités) -- [Technologies](#technologies) -- [Installation rapide](#installation-rapide) -- [Documentation](#documentation) -- [Architecture](#architecture) -- [Utilisation](#utilisation) -- [Déploiement](#déploiement) -- [Maintenance](#maintenance) -- [Support](#support) - ---- - -## 🎯 Aperçu - -L'application **Gestion des Formations Manager Itinova** est une solution complète pour gérer l'ensemble du cycle de vie des formations : - -- Création et planification des formations et séquences -- Gestion des apprenants et inscriptions avec validation automatique -- Génération automatique de documents (listes, feuilles de présence) -- Envoi automatique d'emails (confirmations, rappels) -- Calendrier interactif et système de rappels -- Rapports et statistiques détaillés - -L'application offre **deux modes d'authentification** : -- **Connexion Manus OAuth** : pour les utilisateurs avec compte Manus (Google, Microsoft, Apple) -- **Connexion locale** : pour les administrateurs avec identifiant et mot de passe - ---- - -## ✨ Fonctionnalités - -### 🔐 Authentification dual - -- Page de choix de connexion -- Authentification Manus OAuth (Google, Microsoft, Apple) -- Authentification locale par identifiant/mot de passe -- Gestion des rôles (admin, user) -- Utilisateur administrateur permanent non supprimable - -### 📚 Gestion des formations - -- CRUD complet des formations -- Support de multiples séquences par formation -- Jusqu'à 4 dates par séquence -- Champs personnalisés : - - Public cible (Directeurs, Chefs de service, Autre, Tous) - - Formateur avec autocomplétion - - Capacité maximale par séquence - - Lieu de formation -- Barre de progression colorée (vert/orange/rouge) selon le taux de remplissage -- Filtres et recherche avancée - -### 👥 Gestion des apprenants - -- CRUD complet des apprenants -- Fonction (Directeur, Chef de service, Autre) -- Page de détail par apprenant avec historique complet -- Système d'inscription avec : - - Validation de capacité automatique - - Détection anti-doublons - - Blocage automatique à J-15 - - Alerte si public cible incompatible - -### 📅 Calendrier et rappels - -- Calendrier mensuel interactif -- Navigation par mois -- Visualisation des séquences par date -- Système de rappels automatiques configurables : - - Rappel J-7 - - Rappel J-3 - - Rappel J-1 - - Rappel personnalisé - -### 📄 Exports et documents - -- **Export Excel** : listes d'inscrits avec toutes les informations -- **Export PDF** : listes d'inscrits formatées -- **Feuilles de présence PDF** : - - Logo Itinova - - Double signature (matin/après-midi) - - Signature formateur - - Informations complètes de la séquence - -### ✉️ Communications automatiques - -- **Génération d'invitations Outlook** (.ics) -- **Emails automatiques** : - - Email de confirmation d'inscription - - Email teaser (présentation de la formation) - - Email de rappel J-7 -- **Templates personnalisables** avec variables dynamiques -- **Configuration SMTP** avec Resend -- Mode test et production - -### 📊 Rapports et statistiques - -- Tableau de bord avec indicateurs clés -- Répartition des apprenants par fonction -- Analyse public cible vs fonction réelle -- Statistiques de remplissage des séquences -- Historique des inscriptions - -### 👤 Administration - -- Gestion des utilisateurs -- Création, modification, activation/désactivation -- Attribution des rôles -- Utilisateur admin permanent (adminServFormation) -- Templates d'emails personnalisables -- Configuration SMTP centralisée - ---- - -## 🛠 Technologies - -### Frontend - -- **React 19** : bibliothèque UI moderne -- **Tailwind CSS 4** : framework CSS utility-first -- **shadcn/ui** : composants UI accessibles -- **Wouter** : routage léger -- **TanStack Query** : gestion des données asynchrones -- **Recharts** : visualisation de données -- **date-fns** : manipulation de dates - -### Backend - -- **Node.js 22** : runtime JavaScript -- **Express 4** : framework web -- **tRPC 11** : API type-safe end-to-end -- **Drizzle ORM** : ORM TypeScript-first -- **MySQL/TiDB** : base de données relationnelle - -### Outils - -- **TypeScript** : typage statique -- **Vite** : build tool rapide -- **pnpm** : gestionnaire de paquets performant -- **Vitest** : framework de tests - -### Services externes - -- **Resend** : envoi d'emails transactionnels -- **Manus OAuth** : authentification sociale - ---- - -## 🚀 Installation rapide - -### Prérequis - -- Node.js 22+ -- MySQL 8.0+ ou MariaDB 10.6+ -- pnpm (installé automatiquement avec Node.js) - -### Installation en 4 étapes - -```bash -# 1. Installer les dépendances -pnpm install - -# 2. Configurer les variables d'environnement -cp .env.example .env -# Éditer .env avec vos paramètres - -# 3. Créer et initialiser la base de données -mysql -u root -p -e "CREATE DATABASE formation_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" -pnpm db:push - -# 4. Démarrer l'application -pnpm dev -``` - -L'application sera accessible à **http://localhost:3000** - -### Connexion initiale - -- **Identifiant** : `adminServFormation` -- **Mot de passe** : `Itinova69!` - -📖 **Guide complet** : voir [INSTALL.md](INSTALL.md) - ---- - -## 📚 Documentation - -- **[INSTALL.md](INSTALL.md)** : Guide d'installation rapide (5 minutes) -- **[DEPLOYMENT.md](DEPLOYMENT.md)** : Guide de déploiement complet -- **[deploy.sh](deploy.sh)** : Script de déploiement automatique - ---- - -## 🏗 Architecture - -### Structure du projet - -``` -formation-manager-itinova/ -├── client/ # Application frontend -│ ├── public/ # Fichiers statiques -│ │ └── logo.svg # Logo de l'application -│ └── src/ -│ ├── pages/ # Pages de l'application -│ │ ├── LoginChoice.tsx -│ │ ├── Login.tsx -│ │ ├── Dashboard.tsx -│ │ ├── Formations.tsx -│ │ ├── Sequences.tsx -│ │ ├── Apprenants.tsx -│ │ └── ... -│ ├── components/ # Composants réutilisables -│ │ ├── DashboardLayout.tsx -│ │ └── ui/ # Composants shadcn/ui -│ └── lib/ # Bibliothèques et utilitaires -│ └── trpc.ts # Client tRPC -├── server/ # Application backend -│ ├── routers.ts # Routes API tRPC -│ ├── db.ts # Fonctions d'accès BDD -│ └── _core/ # Configuration serveur -│ ├── index.ts # Point d'entrée -│ ├── trpc.ts # Configuration tRPC -│ ├── localAuth.ts # Authentification locale -│ └── ... -├── drizzle/ # Base de données -│ └── schema.ts # Schéma des tables -├── shared/ # Code partagé -│ └── const.ts # Constantes -├── .env # Variables d'environnement -├── package.json # Dépendances -├── deploy.sh # Script de déploiement -├── DEPLOYMENT.md # Guide de déploiement -├── INSTALL.md # Guide d'installation -└── README.md # Ce fichier -``` - -### Base de données - -**Tables principales** : - -- `users` : utilisateurs de l'application -- `formations` : formations disponibles -- `sequences` : séquences de formation (dates) -- `apprenants` : apprenants inscrits -- `inscriptions` : inscriptions aux séquences -- `rappels` : rappels configurés -- `email_templates` : templates d'emails -- `email_config` : configuration SMTP - -### API (tRPC) - -L'API utilise tRPC pour une communication type-safe entre le client et le serveur. - -**Routers principaux** : - -- `auth` : authentification (me, logout) -- `formations` : CRUD formations -- `sequences` : CRUD séquences -- `apprenants` : CRUD apprenants -- `inscriptions` : gestion des inscriptions -- `rappels` : gestion des rappels -- `emails` : envoi d'emails et gestion des templates -- `users` : gestion des utilisateurs -- `stats` : statistiques et rapports - ---- - -## 💻 Utilisation - -### Commandes disponibles - -```bash -# Développement -pnpm dev # Démarrer le serveur de développement -pnpm build # Builder pour la production -pnpm start # Démarrer en mode production - -# Base de données -pnpm db:push # Appliquer les migrations -pnpm db:generate # Générer les migrations -pnpm db:studio # Ouvrir Drizzle Studio - -# Tests -pnpm test # Lancer les tests -pnpm test:ui # Interface de tests Vitest - -# Linting -pnpm lint # Vérifier le code -pnpm format # Formater le code -``` - -### Workflow typique - -1. **Créer une formation** - - Menu "Formations" → "Nouvelle formation" - - Remplir les informations - - Sauvegarder - -2. **Créer des séquences** - - Cliquer sur la formation - - "Ajouter une séquence" - - Définir les dates (jusqu'à 4) - - Définir la capacité maximale - -3. **Ajouter des apprenants** - - Menu "Apprenants" → "Ajouter un apprenant" - - Remplir nom, prénom, email, fonction - -4. **Gérer les inscriptions** - - Depuis la page de la séquence - - Ou depuis la page de l'apprenant - - Validation automatique de la capacité - -5. **Configurer les rappels** - - Menu "Rappels" - - Activer les rappels souhaités (J-7, J-3, J-1) - - Les emails seront envoyés automatiquement - -6. **Exporter les documents** - - Liste des inscrits (Excel ou PDF) - - Feuille de présence (PDF) - - Invitation Outlook (.ics) - ---- - -## 🌐 Déploiement - -### Option 1 : Plateforme Manus (recommandé) - -L'application est optimisée pour la plateforme Manus : - -1. Créer un checkpoint -2. Cliquer sur "Publish" -3. Configurer le domaine -4. Publier - -**Avantages** : déploiement automatique, SSL, scaling, base de données incluse. - -### Option 2 : Serveur VPS - -Utiliser le script de déploiement automatique : - -```bash -# Rendre le script exécutable -chmod +x deploy.sh - -# Lancer le déploiement -./deploy.sh -``` - -Le script effectue automatiquement : -- Vérification des prérequis -- Installation des dépendances -- Sauvegarde de la base de données -- Application des migrations -- Build de l'application -- Redémarrage du service - -📖 **Guide complet** : voir [DEPLOYMENT.md](DEPLOYMENT.md) - ---- - -## 🔧 Maintenance - -### Sauvegardes automatiques - -Le script de déploiement crée automatiquement des sauvegardes de la base de données. - -Pour configurer des sauvegardes régulières : - -```bash -# Ajouter une tâche cron -crontab -e - -# Sauvegarde quotidienne à 2h du matin -0 2 * * * /chemin/vers/backup.sh -``` - -### Mise à jour - -```bash -# Récupérer les dernières modifications -git pull origin main - -# Lancer le déploiement -./deploy.sh -``` - -### Logs - -```bash -# Logs de l'application (systemd) -sudo journalctl -u formation-manager -f - -# Logs Nginx -sudo tail -f /var/log/nginx/access.log -sudo tail -f /var/log/nginx/error.log -``` - ---- - -## 📞 Support - -### Documentation - -- [Guide d'installation](INSTALL.md) -- [Guide de déploiement](DEPLOYMENT.md) -- Code source commenté - -### Dépannage - -Consultez la section "Dépannage" dans [DEPLOYMENT.md](DEPLOYMENT.md) pour les problèmes courants. - -### Contact - -Pour toute question ou problème : -- Consulter la documentation -- Vérifier les logs de l'application -- Contacter l'équipe de développement - ---- - -## 📝 Licence - -Ce projet est la propriété de Manager Itinova. Tous droits réservés. - ---- - -## 🙏 Crédits - -Développé avec ❤️ pour Manager Itinova - -**Technologies utilisées** : -- React, Tailwind CSS, shadcn/ui -- Node.js, Express, tRPC -- Drizzle ORM, MySQL -- Resend, Manus OAuth - ---- - -**Version** : 1.0.0 -**Dernière mise à jour** : 23 novembre 2025 diff --git a/drizzle/meta/0017_snapshot.json b/deployment-package/drizzle/meta/0000_snapshot.json similarity index 90% rename from drizzle/meta/0017_snapshot.json rename to deployment-package/drizzle/meta/0000_snapshot.json index 9916bee..d0ae91e 100644 --- a/drizzle/meta/0017_snapshot.json +++ b/deployment-package/drizzle/meta/0000_snapshot.json @@ -1,8 +1,8 @@ { "version": "5", "dialect": "mysql", - "id": "0084eddc-561c-4ad1-a76b-da67dbd0f00a", - "prevId": "e32d3d82-3520-4f4d-888a-1faa027d6128", + "id": "aa771876-44ab-4786-a5db-87b71c6e5674", + "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "apprenants": { "name": "apprenants", @@ -175,27 +175,6 @@ "notNull": false, "autoincrement": false }, - "signatureUrl": { - "name": "signatureUrl", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "signatureS3Key": { - "name": "signatureS3Key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "dateSigned": { - "name": "dateSigned", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "dateGeneration": { "name": "dateGeneration", "type": "timestamp", @@ -378,9 +357,9 @@ "autoincrement": false, "default": "'simulation'" }, - "apiKey": { - "name": "apiKey", - "type": "text", + "resendApiKey": { + "name": "resendApiKey", + "type": "varchar(255)", "primaryKey": false, "notNull": false, "autoincrement": false @@ -401,21 +380,21 @@ }, "smtpSecure": { "name": "smtpSecure", - "type": "enum('none','tls','ssl')", + "type": "enum('tls','ssl','none')", "primaryKey": false, "notNull": false, "autoincrement": false }, "smtpUser": { "name": "smtpUser", - "type": "varchar(320)", + "type": "varchar(255)", "primaryKey": false, "notNull": false, "autoincrement": false }, "smtpPassword": { "name": "smtpPassword", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": false, "autoincrement": false @@ -432,8 +411,7 @@ "type": "varchar(255)", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": "'Formation Manager Itinova'" + "autoincrement": false }, "mode": { "name": "mode", @@ -443,22 +421,6 @@ "autoincrement": false, "default": "'simulation'" }, - "domainVerified": { - "name": "domainVerified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "active": { - "name": "active", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, "createdAt": { "name": "createdAt", "type": "timestamp", @@ -502,7 +464,7 @@ }, "type": { "name": "type", - "type": "enum('inscription','teaser','rappel','rappelJ1','rappel1','rappel2','rappel3','rappel4','rappel5','rappel6','reset_password','attestation')", + "type": "enum('inscription','teaser','rappel','rappelJ1','rappel3','rappel4','rappel5','rappel6')", "primaryKey": false, "notNull": true, "autoincrement": false @@ -865,14 +827,6 @@ "notNull": false, "autoincrement": false }, - "modeEnvoi": { - "name": "modeEnvoi", - "type": "enum('manuel','auto')", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'manuel'" - }, "createdAt": { "name": "createdAt", "type": "timestamp", @@ -1037,25 +991,18 @@ }, "type": { "name": "type", - "type": "enum('remerciement','notification_formateur_inscription','notification_formateur_annulation','alerte_capacite','notification_liste_attente','attestation')", + "type": "enum('inscription','annulation','capacite_atteinte','place_disponible','remerciement','formateur_inscription','formateur_annulation')", "primaryKey": false, "notNull": true, "autoincrement": false }, - "emailDestinataire": { - "name": "emailDestinataire", + "destinataire": { + "name": "destinataire", "type": "varchar(320)", "primaryKey": false, "notNull": true, "autoincrement": false }, - "sujet": { - "name": "sujet", - "type": "varchar(500)", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "sequenceId": { "name": "sequenceId", "type": "int", @@ -1070,16 +1017,9 @@ "notNull": false, "autoincrement": false }, - "formateurId": { - "name": "formateurId", - "type": "int", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "statut": { "name": "statut", - "type": "enum('success','failed')", + "type": "enum('succes','echec')", "primaryKey": false, "notNull": true, "autoincrement": false @@ -1091,21 +1031,6 @@ "notNull": false, "autoincrement": false }, - "metadata": { - "name": "metadata", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "modeEnvoi": { - "name": "modeEnvoi", - "type": "enum('manuel','auto')", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'manuel'" - }, "dateEnvoi": { "name": "dateEnvoi", "type": "timestamp", @@ -1128,8 +1053,8 @@ "uniqueConstraints": {}, "checkConstraint": {} }, - "logsrappels": { - "name": "logsrappels", + "logsRappels": { + "name": "logsRappels", "columns": { "id": { "name": "id", @@ -1168,19 +1093,11 @@ }, "type": { "name": "type", - "type": "enum('rappel1','rappel2','rappel3','rappel4','rappel5','rappel6')", + "type": "enum('rappel','rappelJ1','rappel3','rappel4','rappel5','rappel6')", "primaryKey": false, "notNull": true, "autoincrement": false }, - "typeEnvoi": { - "name": "typeEnvoi", - "type": "enum('automatique','test')", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'automatique'" - }, "statut": { "name": "statut", "type": "enum('succes','echec')", @@ -1222,8 +1139,8 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": { - "logsrappels_id": { - "name": "logsrappels_id", + "logsRappels_id": { + "name": "logsRappels_id", "columns": [ "id" ] @@ -1307,9 +1224,9 @@ "notNull": true, "autoincrement": true }, - "email": { - "name": "email", - "type": "varchar(320)", + "userId": { + "name": "userId", + "type": "int", "primaryKey": false, "notNull": true, "autoincrement": false @@ -1328,14 +1245,6 @@ "notNull": true, "autoincrement": false }, - "used": { - "name": "used", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, "createdAt": { "name": "createdAt", "type": "timestamp", @@ -1379,7 +1288,7 @@ "name": "inscriptionId", "type": "int", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "dateFormationId": { @@ -1389,29 +1298,6 @@ "notNull": true, "autoincrement": false }, - "typeUtilisateur": { - "name": "typeUtilisateur", - "type": "enum('apprenant','formateur')", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'apprenant'" - }, - "formateurId": { - "name": "formateurId", - "type": "int", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "periode": { - "name": "periode", - "type": "enum('matin','apres_midi')", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'matin'" - }, "heurePresence": { "name": "heurePresence", "type": "timestamp", @@ -1434,27 +1320,6 @@ "notNull": false, "autoincrement": false }, - "commentaire": { - "name": "commentaire", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "signatureUrl": { - "name": "signatureUrl", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "signatureS3Key": { - "name": "signatureS3Key", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "createdAt": { "name": "createdAt", "type": "timestamp", @@ -1701,7 +1566,7 @@ }, "templateType": { "name": "templateType", - "type": "enum('rappel1','rappel2','rappel3','rappel4','rappel5','rappel6')", + "type": "enum('rappel','rappelJ1','rappel3','rappel4','rappel5','rappel6')", "primaryKey": false, "notNull": true, "autoincrement": false @@ -1764,41 +1629,6 @@ "notNull": false, "autoincrement": false }, - "nomFichier2": { - "name": "nomFichier2", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "urlFichier2": { - "name": "urlFichier2", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "s3Key2": { - "name": "s3Key2", - "type": "varchar(500)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "typeFichier2": { - "name": "typeFichier2", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "tailleFichier2": { - "name": "tailleFichier2", - "type": "int", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "actif": { "name": "actif", "type": "boolean", @@ -2019,13 +1849,6 @@ "notNull": false, "autoincrement": false }, - "dateBlocage": { - "name": "dateBlocage", - "type": "datetime", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "qrCodeToken": { "name": "qrCodeToken", "type": "varchar(100)", @@ -2213,7 +2036,7 @@ }, "role": { "name": "role", - "type": "enum('user','admin','formateur','super_formateur')", + "type": "enum('user','admin','formateur')", "primaryKey": false, "notNull": true, "autoincrement": false, diff --git a/deployment-package/drizzle/meta/_journal.json b/deployment-package/drizzle/meta/_journal.json new file mode 100644 index 0000000..6f7b99a --- /dev/null +++ b/deployment-package/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1768296151204, + "tag": "0000_clean_justice", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/deployment-package/drizzle/relations.ts b/deployment-package/drizzle/relations.ts new file mode 100644 index 0000000..941a268 --- /dev/null +++ b/deployment-package/drizzle/relations.ts @@ -0,0 +1 @@ +import {} from "./schema"; diff --git a/deployment-package/drizzle/schema.ts b/deployment-package/drizzle/schema.ts new file mode 100644 index 0000000..e0d9b2d --- /dev/null +++ b/deployment-package/drizzle/schema.ts @@ -0,0 +1,549 @@ +import { boolean, int, mysqlEnum, mysqlTable, text, timestamp, varchar, datetime, decimal } from "drizzle-orm/mysql-core"; + +/** + * Core user table backing auth flow. + * Extend this file with additional tables as your product grows. + * Columns use camelCase to match both database fields and generated types. + */ +export const users = mysqlTable("users", { + /** + * Surrogate primary key. Auto-incremented numeric value managed by the database. + * Use this for relations between tables. + */ + id: int("id").autoincrement().primaryKey(), + /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */ + openId: varchar("openId", { length: 64 }).notNull().unique(), + name: text("name"), + email: varchar("email", { length: 320 }), + /** Identifiant de connexion unique pour l'utilisateur */ + username: varchar("username", { length: 100 }).unique(), + /** Mot de passe hashé (bcrypt) */ + password: varchar("password", { length: 255 }), + loginMethod: varchar("loginMethod", { length: 64 }), + role: mysqlEnum("role", ["user", "admin", "formateur"]).default("user").notNull(), + /** Statut du compte (actif/inactif) */ + isActive: boolean("isActive").default(true).notNull(), + /** ID du formateur associé (si role = formateur) */ + formateurId: int("formateurId"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), +}); + +export type User = typeof users.$inferSelect; +export type InsertUser = typeof users.$inferInsert; + +/** + * Table des tokens de réinitialisation de mot de passe + */ +export const passwordResetTokens = mysqlTable("passwordResetTokens", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + token: varchar("token", { length: 255 }).notNull().unique(), + expiresAt: timestamp("expiresAt").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; +export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert; + +/** + * Table des formations (ex: Manager Itinova) + */ +export const formations = mysqlTable("formations", { + id: int("id").autoincrement().primaryKey(), + nom: varchar("nom", { length: 255 }).notNull(), + description: text("description"), + /** Lien unique pour l'inscription à cette formation */ + lienUnique: varchar("lienUnique", { length: 100 }).notNull().unique(), + actif: boolean("actif").default(true).notNull(), + /** Mode de génération des attestations (auto = génération automatique, manuel = import de documents) */ + modeAttestation: mysqlEnum("modeAttestation", ["auto", "manuel"]).default("auto").notNull(), + /** Mode d'envoi des attestations (auto = envoi automatique, manuel = envoi manuel) */ + modeEnvoi: mysqlEnum("modeEnvoi", ["auto", "manuel"]).default("manuel").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Formation = typeof formations.$inferSelect; +export type InsertFormation = typeof formations.$inferInsert; + +/** + * Table des séquences de formation (ex: Groupe A, Groupe B) + * Chaque séquence peut avoir jusqu'à 4 dates de formation + */ +export const sequences = mysqlTable("sequences", { + id: int("id").autoincrement().primaryKey(), + formationId: int("formationId").notNull(), + nom: varchar("nom", { length: 255 }).notNull(), + lieu: varchar("lieu", { length: 255 }).notNull(), + capaciteMax: int("capaciteMax").notNull(), + statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee", "fermee", "annulee"]).default("ouverte").notNull(), + /** Public cible de la séquence (Directeur, Chef de service, Autre, Tous) */ + publicCible: mysqlEnum("publicCible", ["directeur", "chef_service", "autre", "tous"]).default("tous").notNull(), + /** ID du formateur assigné à cette séquence */ + formateurId: int("formateurId"), + /** Token unique pour le QR code d'émargement */ + qrCodeToken: varchar("qrCodeToken", { length: 100 }).unique(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Sequence = typeof sequences.$inferSelect; +export type InsertSequence = typeof sequences.$inferInsert; + +/** + * Table des dates de formation pour chaque séquence + * Une séquence peut avoir jusqu'à 4 dates + */ +export const datesFormation = mysqlTable("datesFormation", { + id: int("id").autoincrement().primaryKey(), + sequenceId: int("sequenceId").notNull(), + /** Ordre de la date (1, 2, 3 ou 4) */ + ordre: int("ordre").notNull(), + dateDebut: datetime("dateDebut").notNull(), + dateFin: datetime("dateFin").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type DateFormation = typeof datesFormation.$inferSelect; +export type InsertDateFormation = typeof datesFormation.$inferInsert; + +/** + * Table des apprenants + */ +export const apprenants = mysqlTable("apprenants", { + id: int("id").autoincrement().primaryKey(), + nom: varchar("nom", { length: 255 }).notNull(), + prenom: varchar("prenom", { length: 255 }).notNull(), + email: varchar("email", { length: 320 }).notNull().unique(), + codeEtablissement: varchar("codeEtablissement", { length: 50 }), + /** Fonction de l'apprenant (Directeur, Chef de service, Autre) */ + fonction: varchar("fonction", { length: 100 }), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Apprenant = typeof apprenants.$inferSelect; +export type InsertApprenant = typeof apprenants.$inferInsert; + +/** + * Table des inscriptions + */ +export const inscriptions = mysqlTable("inscriptions", { + id: int("id").autoincrement().primaryKey(), + sequenceId: int("sequenceId").notNull(), + apprenantId: int("apprenantId").notNull(), + statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(), + dateInscription: timestamp("dateInscription").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Inscription = typeof inscriptions.$inferSelect; +export type InsertInscription = typeof inscriptions.$inferInsert; + +/** + * Table de configuration des emails + */ +export const emailConfig = mysqlTable("emailConfig", { + id: int("id").autoincrement().primaryKey(), + /** Provider d'envoi d'emails (resend, smtp, simulation) */ + provider: mysqlEnum("provider", ["resend", "smtp", "simulation"]).default("simulation").notNull(), + /** Clé API Resend */ + resendApiKey: varchar("resendApiKey", { length: 255 }), + /** Hôte SMTP */ + smtpHost: varchar("smtpHost", { length: 255 }), + /** Port SMTP */ + smtpPort: int("smtpPort"), + /** Sécurité SMTP (TLS, SSL, None) */ + smtpSecure: mysqlEnum("smtpSecure", ["tls", "ssl", "none"]), + /** Utilisateur SMTP */ + smtpUser: varchar("smtpUser", { length: 255 }), + /** Mot de passe SMTP */ + smtpPassword: varchar("smtpPassword", { length: 255 }), + /** Email expéditeur */ + fromEmail: varchar("fromEmail", { length: 320 }).notNull(), + /** Nom de l'expéditeur */ + fromName: varchar("fromName", { length: 255 }).notNull(), + /** Mode de fonctionnement (simulation ou production) */ + mode: mysqlEnum("mode", ["simulation", "production"]).default("simulation").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type EmailConfig = typeof emailConfig.$inferSelect; +export type InsertEmailConfig = typeof emailConfig.$inferInsert; + +/** + * Table des templates d'emails + */ +export const emailTemplates = mysqlTable("emailTemplates", { + id: int("id").autoincrement().primaryKey(), + /** Type de template (inscription, teaser, rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */ + type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull().unique(), + titre: varchar("titre", { length: 255 }).notNull(), + /** Corps du message avec variables dynamiques */ + bodyContent: text("bodyContent").notNull(), + couleurPrincipale: varchar("couleurPrincipale", { length: 7 }).default("#283581").notNull(), + couleurSecondaire: varchar("couleurSecondaire", { length: 7 }).default("#0578BE").notNull(), + piedDePage: text("piedDePage"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type EmailTemplate = typeof emailTemplates.$inferSelect; +export type InsertEmailTemplate = typeof emailTemplates.$inferInsert; + +/** + * Table des formateurs + */ +export const formateurs = mysqlTable("formateurs", { + id: int("id").autoincrement().primaryKey(), + nom: varchar("nom", { length: 255 }).notNull(), + email: varchar("email", { length: 320 }), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Formateur = typeof formateurs.$inferSelect; +export type InsertFormateur = typeof formateurs.$inferInsert; + +/** + * Table des rappels automatiques + */ +export const rappels = mysqlTable("rappels", { + id: int("id").autoincrement().primaryKey(), + nom: varchar("nom", { length: 255 }).notNull(), + /** Type de template d'email à utiliser (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */ + templateType: mysqlEnum("templateType", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(), + /** Nombre de jours avant la séquence */ + joursAvant: int("joursAvant").notNull(), + /** Heure d'envoi (format HH:mm) */ + heureEnvoi: varchar("heureEnvoi", { length: 5 }).default("09:00").notNull(), + /** Timing du rappel (pre_formation ou post_formation) */ + timing: mysqlEnum("timing", ["pre_formation", "post_formation"]).default("pre_formation").notNull(), + /** Nom du fichier joint (optionnel) */ + nomFichier: varchar("nomFichier", { length: 255 }), + /** URL du fichier sur S3 (optionnel) */ + urlFichier: varchar("urlFichier", { length: 500 }), + /** Clé S3 du fichier (optionnel) */ + s3Key: varchar("s3Key", { length: 500 }), + /** Type MIME du fichier (optionnel) */ + typeFichier: varchar("typeFichier", { length: 100 }), + /** Taille du fichier en octets (optionnel) */ + tailleFichier: int("tailleFichier"), + actif: boolean("actif").default(true).notNull(), + derniereExecution: datetime("derniereExecution"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Rappel = typeof rappels.$inferSelect; +export type InsertRappel = typeof rappels.$inferInsert; + +/** + * Table de liaison entre rappels et dates de formation + * Si aucune date n'est associée, le rappel s'applique à toutes les dates + */ +export const rappelDates = mysqlTable("rappelDates", { + id: int("id").autoincrement().primaryKey(), + rappelId: int("rappelId").notNull(), + dateFormationId: int("dateFormationId").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type RappelDate = typeof rappelDates.$inferSelect; +export type InsertRappelDate = typeof rappelDates.$inferInsert; + +/** + * Table des logs de rappels envoyés + */ +export const logsRappels = mysqlTable("logsRappels", { + id: int("id").autoincrement().primaryKey(), + rappelId: int("rappelId").notNull(), + sequenceId: int("sequenceId").notNull(), + apprenantId: int("apprenantId").notNull(), + email: varchar("email", { length: 320 }).notNull(), + /** Type de rappel (rappel, rappelJ1, rappel3, rappel4, rappel5, rappel6) */ + type: mysqlEnum("type", ["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).notNull(), + statut: mysqlEnum("statut", ["succes", "echec"]).notNull(), + messageErreur: text("messageErreur"), + /** Nombre de tentatives d'envoi */ + nbTentatives: int("nbTentatives").default(1).notNull(), + /** Date du prochain essai en cas d'échec */ + prochainEssai: datetime("prochainEssai"), + dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(), +}); + +export type LogRappel = typeof logsRappels.$inferSelect; +export type InsertLogRappel = typeof logsRappels.$inferInsert; + +/** + * Table des questionnaires de satisfaction et d'évaluation + */ +export const questionnaires = mysqlTable("questionnaires", { + id: int("id").autoincrement().primaryKey(), + titre: varchar("titre", { length: 255 }).notNull(), + description: text("description"), + /** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */ + type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(), + actif: boolean("actif").default(true).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Questionnaire = typeof questionnaires.$inferSelect; +export type InsertQuestionnaire = typeof questionnaires.$inferInsert; + +/** + * Table des questions d'un questionnaire + */ +export const questions = mysqlTable("questions", { + id: int("id").autoincrement().primaryKey(), + questionnaireId: int("questionnaireId").notNull(), + texte: text("texte").notNull(), + /** Type de question (choix_multiple, echelle, texte_libre, oui_non) */ + typeQuestion: mysqlEnum("typeQuestion", ["choix_multiple", "echelle", "texte_libre", "oui_non"]).notNull(), + /** Options pour les questions à choix multiples (JSON) */ + options: text("options"), + /** Valeur minimale pour les échelles */ + valeurMin: int("valeurMin"), + /** Valeur maximale pour les échelles */ + valeurMax: int("valeurMax"), + obligatoire: boolean("obligatoire").default(false).notNull(), + ordre: int("ordre").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type Question = typeof questions.$inferSelect; +export type InsertQuestion = typeof questions.$inferInsert; + +/** + * Table des réponses aux questionnaires + */ +export const reponsesQuestionnaires = mysqlTable("reponsesQuestionnaires", { + id: int("id").autoincrement().primaryKey(), + questionnaireId: int("questionnaireId").notNull(), + sequenceId: int("sequenceId").notNull(), + apprenantId: int("apprenantId").notNull(), + dateReponse: timestamp("dateReponse").defaultNow().notNull(), +}); + +export type ReponseQuestionnaire = typeof reponsesQuestionnaires.$inferSelect; +export type InsertReponseQuestionnaire = typeof reponsesQuestionnaires.$inferInsert; + +/** + * Table des réponses individuelles aux questions + */ +export const reponsesQuestions = mysqlTable("reponsesQuestions", { + id: int("id").autoincrement().primaryKey(), + reponseQuestionnaireId: int("reponseQuestionnaireId").notNull(), + questionId: int("questionId").notNull(), + /** Réponse textuelle */ + reponseTexte: text("reponseTexte"), + /** Réponse numérique (pour échelles et oui/non) */ + reponseNumerique: int("reponseNumerique"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type ReponseQuestion = typeof reponsesQuestions.$inferSelect; +export type InsertReponseQuestion = typeof reponsesQuestions.$inferInsert; + +/** + * Table des envois de questionnaires + */ +export const envoisQuestionnaires = mysqlTable("envoisQuestionnaires", { + id: int("id").autoincrement().primaryKey(), + questionnaireId: int("questionnaireId").notNull(), + sequenceId: int("sequenceId").notNull(), + apprenantId: int("apprenantId").notNull(), + /** Token unique pour accéder au questionnaire */ + token: varchar("token", { length: 100 }).notNull().unique(), + dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(), + dateReponse: timestamp("dateReponse"), + statut: mysqlEnum("statut", ["envoye", "repondu"]).default("envoye").notNull(), +}); + +export type EnvoiQuestionnaire = typeof envoisQuestionnaires.$inferSelect; +export type InsertEnvoiQuestionnaire = typeof envoisQuestionnaires.$inferInsert; + +/** + * Table des supports de formation uploadés par les formateurs + */ +export const supportsFormation = mysqlTable("supportsFormation", { + id: int("id").autoincrement().primaryKey(), + sequenceId: int("sequenceId").notNull(), + formateurId: int("formateurId").notNull(), + nomFichier: varchar("nomFichier", { length: 255 }).notNull(), + typeFichier: varchar("typeFichier", { length: 100 }).notNull(), + tailleFichier: int("tailleFichier").notNull(), + urlFichier: varchar("urlFichier", { length: 500 }).notNull(), + s3Key: varchar("s3Key", { length: 500 }).notNull(), + description: text("description"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type SupportFormation = typeof supportsFormation.$inferSelect; +export type InsertSupportFormation = typeof supportsFormation.$inferInsert; + +/** + * Table des présences (émargement) + */ +export const presences = mysqlTable("presences", { + id: int("id").autoincrement().primaryKey(), + inscriptionId: int("inscriptionId").notNull(), + dateFormationId: int("dateFormationId").notNull(), + heurePresence: timestamp("heurePresence").defaultNow().notNull(), + /** Mode de validation (qrcode, manuel) */ + modeValidation: mysqlEnum("modeValidation", ["qrcode", "manuel"]).notNull(), + /** ID de l'utilisateur qui a validé (formateur ou admin) */ + validateurId: int("validateurId"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type Presence = typeof presences.$inferSelect; +export type InsertPresence = typeof presences.$inferInsert; + +/** + * Table des attestations de formation + */ +export const attestations = mysqlTable("attestations", { + id: int("id").autoincrement().primaryKey(), + apprenantId: int("apprenantId").notNull(), + sequenceId: int("sequenceId").notNull(), + inscriptionId: int("inscriptionId").notNull(), + urlPdf: varchar("urlPdf", { length: 500 }), + s3Key: varchar("s3Key", { length: 500 }), + /** URL du document uploadé manuellement */ + documentUrl: varchar("documentUrl", { length: 500 }), + /** Clé S3 du document uploadé manuellement */ + documentS3Key: varchar("documentS3Key", { length: 500 }), + /** ID de l'utilisateur qui a uploadé le document */ + uploadedBy: int("uploadedBy"), + /** Date d'upload du document */ + uploadedAt: timestamp("uploadedAt"), + /** Email envoyé ou non */ + emailEnvoye: boolean("emailEnvoye").default(false).notNull(), + /** Date d'envoi de l'email */ + dateEnvoiEmail: timestamp("dateEnvoiEmail"), + dateGeneration: timestamp("dateGeneration").defaultNow().notNull(), +}); + +export type Attestation = typeof attestations.$inferSelect; +export type InsertAttestation = typeof attestations.$inferInsert; + +/** + * Table de configuration des attestations + */ +export const configAttestation = mysqlTable("configAttestation", { + id: int("id").autoincrement().primaryKey(), + /** Texte personnalisable de l'attestation avec variables dynamiques */ + texteAttestation: text("texteAttestation").notNull(), + /** Nom du signataire */ + nomSignataire: varchar("nomSignataire", { length: 255 }).notNull(), + /** Fonction du signataire */ + fonctionSignataire: varchar("fonctionSignataire", { length: 255 }).notNull(), + /** URL du logo sur S3 */ + logoUrl: varchar("logoUrl", { length: 500 }), + /** Clé S3 du logo */ + logoS3Key: varchar("logoS3Key", { length: 500 }), + /** URL de la signature sur S3 */ + signatureUrl: varchar("signatureUrl", { length: 500 }), + /** Clé S3 de la signature */ + signatureS3Key: varchar("signatureS3Key", { length: 500 }), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type ConfigAttestation = typeof configAttestation.$inferSelect; +export type InsertConfigAttestation = typeof configAttestation.$inferInsert; + +/** + * Table des notifications envoyées + */ +export const logsNotifications = mysqlTable("logsNotifications", { + id: int("id").autoincrement().primaryKey(), + /** Type de notification */ + type: mysqlEnum("type", [ + "inscription", + "annulation", + "capacite_atteinte", + "place_disponible", + "remerciement", + "formateur_inscription", + "formateur_annulation" + ]).notNull(), + /** Email du destinataire */ + destinataire: varchar("destinataire", { length: 320 }).notNull(), + /** ID de la séquence concernée */ + sequenceId: int("sequenceId"), + /** ID de l'apprenant concerné */ + apprenantId: int("apprenantId"), + /** Statut de l'envoi */ + statut: mysqlEnum("statut", ["succes", "echec"]).notNull(), + /** Message d'erreur en cas d'échec */ + messageErreur: text("messageErreur"), + dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(), +}); + +export type LogNotification = typeof logsNotifications.$inferSelect; +export type InsertLogNotification = typeof logsNotifications.$inferInsert; + +/** + * Table des paramètres de l'application + * Stocke les paramètres globaux de configuration + */ +export const parametres = mysqlTable("parametres", { + id: int("id").autoincrement().primaryKey(), + /** URL publique de l'application pour les QR codes d'émargement */ + 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) */ + delaiExpirationQR: int("delaiExpirationQR").default(30).notNull(), + /** Durée de validité des tokens d'émargement en heures (par défaut 24 heures) */ + dureeValiditeToken: int("dureeValiditeToken").default(24).notNull(), + /** Activer ou désactiver les notifications automatiques */ + notificationsActives: boolean("notificationsActives").default(true).notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type Parametre = typeof parametres.$inferSelect; +export type InsertParametre = typeof parametres.$inferInsert; + +/** + * Table de l'historique des modifications des paramètres + * Permet de tracer qui a modifié quoi et quand + */ +export const historiqueParametres = mysqlTable("historiqueParametres", { + id: int("id").autoincrement().primaryKey(), + /** ID de l'utilisateur qui a effectué la modification */ + userId: int("userId").notNull(), + /** Nom de l'utilisateur (pour historique même si user supprimé) */ + userName: varchar("userName", { length: 255 }).notNull(), + /** Champ modifié */ + champModifie: varchar("champModifie", { length: 100 }).notNull(), + /** Ancienne valeur (JSON) */ + ancienneValeur: text("ancienneValeur"), + /** Nouvelle valeur (JSON) */ + nouvelleValeur: text("nouvelleValeur"), + dateModification: timestamp("dateModification").defaultNow().notNull(), +}); + +export type HistoriqueParametre = typeof historiqueParametres.$inferSelect; +export type InsertHistoriqueParametre = typeof historiqueParametres.$inferInsert; + +/** + * Table de l'historique des envois d'attestations de formation + * Permet de tracer tous les envois d'attestations + */ +export const historiqueAttestations = mysqlTable("historiqueAttestations", { + id: int("id").autoincrement().primaryKey(), + sequenceId: int("sequenceId").notNull(), + apprenantId: int("apprenantId").notNull(), + dateEnvoi: timestamp("dateEnvoi").defaultNow().notNull(), + statut: mysqlEnum("statut", ["envoye", "erreur"]).notNull(), + messageErreur: text("messageErreur"), + urlAttestation: varchar("urlAttestation", { length: 500 }), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect; +export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert; diff --git a/deployment-package/package.json b/deployment-package/package.json new file mode 100644 index 0000000..6ed978c --- /dev/null +++ b/deployment-package/package.json @@ -0,0 +1,136 @@ +{ + "name": "formation-manager-itinova", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "scripts": { + "dev": "NODE_ENV=development tsx watch server/_core/index.ts", + "build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", + "start": "NODE_ENV=production node dist/index.js", + "check": "tsc --noEmit", + "format": "prettier --write .", + "test": "vitest run", + "db:push": "drizzle-kit generate && drizzle-kit migrate" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.693.0", + "@aws-sdk/s3-request-presigner": "^3.693.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.2", + "@tiptap/extension-color": "^3.11.0", + "@tiptap/extension-link": "^3.11.0", + "@tiptap/extension-text-style": "^3.11.0", + "@tiptap/react": "^3.11.0", + "@tiptap/starter-kit": "^3.11.0", + "@trpc/client": "^11.6.0", + "@trpc/react-query": "^11.6.0", + "@trpc/server": "^11.6.0", + "@types/multer": "^2.0.0", + "@types/pdfkit": "^0.17.4", + "axios": "^1.12.0", + "bcryptjs": "^3.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "cookie": "^1.0.2", + "date-fns": "^4.1.0", + "dotenv": "^17.2.2", + "drizzle-orm": "^0.44.5", + "embla-carousel-react": "^8.6.0", + "exceljs": "^4.4.0", + "express": "^4.21.2", + "framer-motion": "^12.23.22", + "input-otp": "^1.4.2", + "jose": "6.1.0", + "jsonwebtoken": "^9.0.2", + "jspdf": "^3.0.3", + "jspdf-autotable": "^5.0.2", + "lucide-react": "^0.453.0", + "multer": "^2.0.2", + "mysql2": "^3.15.0", + "nanoid": "^5.1.5", + "next-themes": "^0.4.6", + "nodemailer": "^7.0.11", + "openai": "^4.67.0", + "pdfkit": "^0.17.2", + "qrcode": "^1.5.4", + "react": "^19.1.1", + "react-day-picker": "^9.11.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.64.0", + "react-resizable-panels": "^3.0.6", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "streamdown": "^1.4.0", + "superjson": "^1.13.3", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "wouter": "^3.3.5", + "xlsx": "^0.18.5", + "zod": "^4.1.12" + }, + "devDependencies": { + "@builder.io/vite-plugin-jsx-loc": "^0.1.1", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.1.3", + "@types/bcryptjs": "^3.0.0", + "@types/express": "4.17.21", + "@types/google.maps": "^3.58.1", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.7.0", + "@types/nodemailer": "^7.0.4", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "add": "^2.0.6", + "autoprefixer": "^10.4.20", + "drizzle-kit": "^0.31.4", + "esbuild": "^0.25.0", + "pnpm": "^10.15.1", + "postcss": "^8.4.47", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.14", + "tsx": "^4.19.1", + "tw-animate-css": "^1.4.0", + "typescript": "5.9.3", + "vite": "^7.1.7", + "vite-plugin-manus-runtime": "^0.0.56", + "vitest": "^2.1.4" + }, + "packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af", + "pnpm": { + "patchedDependencies": { + "wouter@3.7.1": "patches/wouter@3.7.1.patch" + }, + "overrides": { + "tailwindcss>nanoid": "3.3.7" + } + } +} \ No newline at end of file diff --git a/deployment-package/pnpm-lock.yaml b/deployment-package/pnpm-lock.yaml new file mode 100644 index 0000000..5bf8408 --- /dev/null +++ b/deployment-package/pnpm-lock.yaml @@ -0,0 +1,11959 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + tailwindcss>nanoid: 3.3.7 + +patchedDependencies: + wouter@3.7.1: + hash: 4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072 + path: patches/wouter@3.7.1.patch + +importers: + + .: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.693.0 + version: 3.907.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.693.0 + version: 3.907.0 + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-aspect-ratio': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-avatar': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-context-menu': + specifier: ^2.2.16 + version: 2.2.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.16 + version: 2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-hover-card': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-menubar': + specifier: ^1.1.16 + version: 1.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-navigation-menu': + specifier: ^1.2.14 + version: 1.2.14(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-scroll-area': + specifier: ^1.2.10 + version: 1.2.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-separator': + specifier: ^1.1.7 + version: 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle-group': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-query': + specifier: ^5.90.2 + version: 5.90.2(react@19.2.0) + '@tiptap/extension-color': + specifier: ^3.11.0 + version: 3.11.0(@tiptap/extension-text-style@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))) + '@tiptap/extension-link': + specifier: ^3.11.0 + version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-text-style': + specifier: ^3.11.0 + version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/react': + specifier: ^3.11.0 + version: 3.11.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tiptap/starter-kit': + specifier: ^3.11.0 + version: 3.11.0 + '@trpc/client': + specifier: ^11.6.0 + version: 11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/react-query': + specifier: ^11.6.0 + version: 11.6.0(@tanstack/react-query@5.90.2(react@19.2.0))(@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + '@trpc/server': + specifier: ^11.6.0 + version: 11.6.0(typescript@5.9.3) + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 + '@types/pdfkit': + specifier: ^0.17.4 + version: 0.17.4 + axios: + specifier: ^1.12.0 + version: 1.12.2 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + cookie: + specifier: ^1.0.2 + version: 1.0.2 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + dotenv: + specifier: ^17.2.2 + version: 17.2.3 + drizzle-orm: + specifier: ^0.44.5 + version: 0.44.6(mysql2@3.15.1) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.0) + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + express: + specifier: ^4.21.2 + version: 4.21.2 + framer-motion: + specifier: ^12.23.22 + version: 12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + jose: + specifier: 6.1.0 + version: 6.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + jspdf: + specifier: ^3.0.3 + version: 3.0.3 + jspdf-autotable: + specifier: ^5.0.2 + version: 5.0.2(jspdf@3.0.3) + lucide-react: + specifier: ^0.453.0 + version: 0.453.0(react@19.2.0) + multer: + specifier: ^2.0.2 + version: 2.0.2 + mysql2: + specifier: ^3.15.0 + version: 3.15.1 + nanoid: + specifier: ^5.1.5 + version: 5.1.6 + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + nodemailer: + specifier: ^7.0.11 + version: 7.0.11 + openai: + specifier: ^4.67.0 + version: 4.104.0(zod@4.1.12) + pdfkit: + specifier: ^0.17.2 + version: 0.17.2 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + react: + specifier: ^19.1.1 + version: 19.2.0 + react-day-picker: + specifier: ^9.11.1 + version: 9.11.1(react@19.2.0) + react-dom: + specifier: ^19.1.1 + version: 19.2.0(react@19.2.0) + react-hook-form: + specifier: ^7.64.0 + version: 7.64.0(react@19.2.0) + react-resizable-panels: + specifier: ^3.0.6 + version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + recharts: + specifier: ^2.15.4 + version: 2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + streamdown: + specifier: ^1.4.0 + version: 1.4.0(@types/react@19.2.2)(react@19.2.0) + superjson: + specifier: ^1.13.3 + version: 1.13.3 + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.1.14) + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + wouter: + specifier: ^3.3.5 + version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.0) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + zod: + specifier: ^4.1.12 + version: 4.1.12 + devDependencies: + '@builder.io/vite-plugin-jsx-loc': + specifier: ^0.1.1 + version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.19(tailwindcss@4.1.14) + '@tailwindcss/vite': + specifier: ^4.1.3 + version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 + '@types/express': + specifier: 4.17.21 + version: 4.17.21 + '@types/google.maps': + specifier: ^3.58.1 + version: 3.58.1 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/node': + specifier: ^24.7.0 + version: 24.7.0 + '@types/nodemailer': + specifier: ^7.0.4 + version: 7.0.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + '@types/react': + specifier: ^19.1.16 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.2.1(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)) + add: + specifier: ^2.0.6 + version: 2.0.6 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.21(postcss@8.5.6) + drizzle-kit: + specifier: ^0.31.4 + version: 0.31.5 + esbuild: + specifier: ^0.25.0 + version: 0.25.10 + pnpm: + specifier: ^10.15.1 + version: 10.18.0 + postcss: + specifier: ^8.4.47 + version: 8.5.6 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + tailwindcss: + specifier: ^4.1.14 + version: 4.1.14 + tsx: + specifier: ^4.19.1 + version: 4.20.6 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.7 + version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) + vite-plugin-manus-runtime: + specifier: ^0.0.56 + version: 0.0.56 + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@24.7.0)(lightningcss@1.30.1) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@9.3.0': + resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.907.0': + resolution: {integrity: sha512-A606SYZtnrVDuuQTsG5fEurHMUeJeqh5TFLx0m3t2x27USuLH0dlw9s2ygFFdAcyzVG7u+my0CIMNQPWida/NA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sesv2@3.940.0': + resolution: {integrity: sha512-jDQ4x2HwB2/UXBS7CTeSDiIb+sVsYGDyxTeXdrRAtqNdGv8kC54fbwokDiJ/mnMyB2gyXWw57BqeDJNkZuLmsw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.907.0': + resolution: {integrity: sha512-ANuu0duNTcQHv0g5YrEuWImT8o9t6li3A+MtAaKxIbTA3eFQnl6xHDxyrbsrU19FtKPg3CWhvfY04j6DaDvR8g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.940.0': + resolution: {integrity: sha512-SdqJGWVhmIURvCSgkDditHRO+ozubwZk9aCX9MK8qxyOndhobCndW1ozl3hX9psvMAo9Q4bppjuqy/GHWpjB+A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.907.0': + resolution: {integrity: sha512-vuIHL8qUcA5oNi7IWSZauCMaXstWTcSsnK1iHcvg92ddGDo1LMd2kQNo0G9UANa8vOfc908+8xKO40gfL8+M7w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.940.0': + resolution: {integrity: sha512-KsGD2FLaX5ngJao1mHxodIVU9VYd1E8810fcYiGwO1PFHDzf5BEkp6D9IdMeQwT8Q6JLYtiiT1Y/o3UCScnGoA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.907.0': + resolution: {integrity: sha512-orqT6djon57y09Ci5q0kezisrEvr78Z+7WvZbq0ZC0Ncul4RgJfCmhcgmzNPaWA18NEI0wGytaxYh3YFE7kIBQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.940.0': + resolution: {integrity: sha512-/G3l5/wbZYP2XEQiOoIkRJmlv15f1P3MSd1a0gz27lHEMrOJOGq66rF1Ca4OJLzapWt3Fy9BPrZAepoAX11kMw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.907.0': + resolution: {integrity: sha512-CKG/0hT4o8K2aQKOe+xwGP3keSNOyryhZNmKuHPuMRVlsJfO6wNxlu37HcUPzihJ+S2pOmTVGUbeVMCxJVUJmw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.940.0': + resolution: {integrity: sha512-dOrc03DHElNBD6N9Okt4U0zhrG4Wix5QUBSZPr5VN8SvmjD9dkrrxOkkJaMCl/bzrW7kbQEp7LuBdbxArMmOZQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.907.0': + resolution: {integrity: sha512-Clz1YdXrgQ5WIlcRE7odHbgM/INBxy49EA3csDITafHaDPtPRL39zkQtB5+Lwrrt/Gg0xBlyTbvP5Snan+0lqA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.940.0': + resolution: {integrity: sha512-gn7PJQEzb/cnInNFTOaDoCN/hOKqMejNmLof1W5VW95Qk0TPO52lH8R4RmJPnRrwFMswOWswTOpR1roKNLIrcw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-login@3.940.0': + resolution: {integrity: sha512-fOKC3VZkwa9T2l2VFKWRtfHQPQuISqqNl35ZhcXjWKVwRwl/o7THPMkqI4XwgT2noGa7LLYVbWMwnsgSsBqglg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.907.0': + resolution: {integrity: sha512-w6Hhc4rV/CFaBliIh9Ph/T59xdGcTF6WmPGzzpykjl68+jcJyUem82hbTVIGaMCpvhx8VRqEr5AEXCXdbDbojw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.940.0': + resolution: {integrity: sha512-M8NFAvgvO6xZjiti5kztFiAYmSmSlG3eUfr4ZHSfXYZUA/KUdZU/D6xJyaLnU8cYRWBludb6K9XPKKVwKfqm4g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.907.0': + resolution: {integrity: sha512-MBWpZqZtKkpM/LOGD5quXvlHJJN8YIP4GKo2ad8y1fEEVydwI8cggyXuauMPV7GllW8d0u3kQUs+4rxm1VaS4w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.940.0': + resolution: {integrity: sha512-pILBzt5/TYCqRsJb7vZlxmRIe0/T+FZPeml417EK75060ajDGnVJjHcuVdLVIeKoTKm9gmJc9l45gon6PbHyUQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.907.0': + resolution: {integrity: sha512-F8I7xwIt0mhdg8NrC70HDmhDRx3ValBvmWH3YkWsjZltWIFozhQCCDISRPhanMkXVhSFmZY0FJ5Lo+B/SZvAAA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.940.0': + resolution: {integrity: sha512-q6JMHIkBlDCOMnA3RAzf8cGfup+8ukhhb50fNpghMs1SNBGhanmaMbZSgLigBRsPQW7fOk2l8jnzdVLS+BB9Uw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.907.0': + resolution: {integrity: sha512-1CmRE/M8LJ/joXm5vUsKkQS35MoWA4xvUH9J1jyCuL3J9A8M+bnTe6ER8fnNLgmEs6ikdmYEIdfijPpBjBpFig==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.940.0': + resolution: {integrity: sha512-9QLTIkDJHHaYL0nyymO41H8g3ui1yz6Y3GmAN1gYQa6plXisuFBnGAbmKVj7zNvjWaOKdF0dV3dd3AFKEDoJ/w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + resolution: {integrity: sha512-mPF3N6eZlVs9G8aBSzvtoxR1RZqMo1aIwR+X8BAZSkhfj55fVF2no4IfPXfdFO3I66N+zEQ8nKoB0uTATWrogQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-expect-continue@3.901.0': + resolution: {integrity: sha512-bwq9nj6MH38hlJwOY9QXIDwa6lI48UsaZpaXbdD71BljEIRlxDzfB4JaYb+ZNNK7RIAdzsP/K05mJty6KJAQHw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.907.0': + resolution: {integrity: sha512-rrJFVsaeGr+bYOzUO2ph9LihFPNq9wcdg/9gcDI3oC9LtOgKeidFgU4t9603WGKGc2eXf4MxzYnaf7LXDOnVvQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.901.0': + resolution: {integrity: sha512-yWX7GvRmqBtbNnUW7qbre3GvZmyYwU0WHefpZzDTYDoNgatuYq6LgUIQ+z5C04/kCRoFkAFrHag8a3BXqFzq5A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.936.0': + resolution: {integrity: sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-location-constraint@3.901.0': + resolution: {integrity: sha512-MuCS5R2ngNoYifkVt05CTULvYVWX0dvRT0/Md4jE3a0u0yMygYy31C1zorwfE/SUgAQXyLmUx8ATmPp9PppImQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.901.0': + resolution: {integrity: sha512-UoHebjE7el/tfRo8/CQTj91oNUm+5Heus5/a4ECdmWaSCHCS/hXTsU3PTTHAY67oAQR8wBLFPfp3mMvXjB+L2A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.936.0': + resolution: {integrity: sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.901.0': + resolution: {integrity: sha512-Wd2t8qa/4OL0v/oDpCHHYkgsXJr8/ttCxrvCKAt0H1zZe2LlRhY9gpDVKqdertfHrHDj786fOvEQA28G1L75Dg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.936.0': + resolution: {integrity: sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.907.0': + resolution: {integrity: sha512-8VVxcZPmJOKI8P08v5ARvoXbLV41abpAIIkt388fp/lwtfzbnXt6sWhhAk/pHgvCR1NnuvkbGuXGVcux59648Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.940.0': + resolution: {integrity: sha512-JYkLjgS1wLoKHJ40G63+afM1ehmsPsjcmrHirKh8+kSCx4ip7+nL1e/twV4Zicxr8RJi9Y0Ahq5mDvneilDDKQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-ssec@3.901.0': + resolution: {integrity: sha512-YiLLJmA3RvjL38mFLuu8fhTTGWtp2qT24VqpucgfoyziYcTgIQkJJmKi90Xp6R6/3VcArqilyRgM1+x8i/em+Q==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.907.0': + resolution: {integrity: sha512-j/h3lk4X6AAXvusx/h8rr0zlo7G0l0quZM4k4rS/9jzatI53HCsrMaiGu6YXbxuVqtfMqv0MAj0MVhaMsAIs4A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.940.0': + resolution: {integrity: sha512-nJbLrUj6fY+l2W2rIB9P4Qvpiy0tnTdg/dmixRxrU1z3e8wBdspJlyE+AZN4fuVbeL6rrRrO/zxQC1bB3cw5IA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.907.0': + resolution: {integrity: sha512-LycXsdC5sMIc+Az5z1Mo2eYShr2kLo2gUgx7Rja3udG0GdqgdR/NNJ6ArmDCeKk2O5RFS5EgEg89bT55ecl5Uw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/nested-clients@3.940.0': + resolution: {integrity: sha512-x0mdv6DkjXqXEcQj3URbCltEzW6hoy/1uIL+i8gExP6YKrnhiZ7SzuB4gPls2UOpK5UqLiqXjhRLfBb1C9i4Dw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.901.0': + resolution: {integrity: sha512-7F0N888qVLHo4CSQOsnkZ4QAp8uHLKJ4v3u09Ly5k4AEStrSlFpckTPyUx6elwGL+fxGjNE2aakK8vEgzzCV0A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.936.0': + resolution: {integrity: sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/s3-request-presigner@3.907.0': + resolution: {integrity: sha512-7RZH4fhCRVq8bacAZd6x+Ko04cmV+Zq4Axk1HZqL3epZ+r4HLR/0NklYZjr/dqMlMx9VbexzNkpBY1VgWxOSyQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.907.0': + resolution: {integrity: sha512-f5XHRu6MTbjB/ud5RwBZzntYMgThRaDur5PJRZ1CaYAL8gZLNuEpJLzUA7o3queeSfE9JboO+cm1gZnOfnYJkg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.940.0': + resolution: {integrity: sha512-ugHZEoktD/bG6mdgmhzLDjMP2VrYRAUPRPF1DpCyiZexkH7DCU7XrSJyXMvkcf0DHV+URk0q2sLf/oqn1D2uYw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.907.0': + resolution: {integrity: sha512-HjPbNft1Ad8X1lHQG21QXy9pitdXA+OKH6NtcXg57A31002tM+SkyUmU6ty1jbsRBEScxziIVe5doI1NmkHheA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.940.0': + resolution: {integrity: sha512-k5qbRe/ZFjW9oWEdzLIa2twRVIEx7p/9rutofyrRysrtEnYh3HAWCngAnwbgKMoiwa806UzcTRx0TjyEpnKcCg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.901.0': + resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.936.0': + resolution: {integrity: sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-arn-parser@3.893.0': + resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.901.0': + resolution: {integrity: sha512-5nZP3hGA8FHEtKvEQf4Aww5QZOkjLW1Z+NixSd+0XKfHvA39Ah5sZboScjLx0C9kti/K3OGW1RCx5K9Zc3bZqg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.936.0': + resolution: {integrity: sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-format-url@3.901.0': + resolution: {integrity: sha512-GGUnJKrh3OF1F3YRSWtwPLbN904Fcfxf03gujyq1rcrDRPEkzoZB+2BzNkB27SsU6lAlwNq+4aRlZRVUloPiag==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.893.0': + resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.907.0': + resolution: {integrity: sha512-Hus/2YCQmtCEfr4Ls88d07Q99Ex59uvtktiPTV963Q7w7LHuIT/JBjrbwNxtSm2KlJR9PHNdqxwN+fSuNsMGMQ==} + + '@aws-sdk/util-user-agent-browser@3.936.0': + resolution: {integrity: sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==} + + '@aws-sdk/util-user-agent-node@3.907.0': + resolution: {integrity: sha512-r2Bc8VCU6ymkuem+QWT6oDdGvaYnK0YHg77SGUF47k+JsztSt1kZR0Y0q8jRH97bOsXldThyEcYsNbqDERa1Uw==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/util-user-agent-node@3.940.0': + resolution: {integrity: sha512-dlD/F+L/jN26I8Zg5x0oDGJiA+/WEQmnSE27fi5ydvYnpfQLwThtQo9SsNS47XSR/SOULaaoC9qx929rZuo74A==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.901.0': + resolution: {integrity: sha512-pxFCkuAP7Q94wMTNPAwi6hEtNrp/BdFf+HOrIEeFQsk4EoOmpKY3I6S+u6A9Wg295J80Kh74LqDWM22ux3z6Aw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/xml-builder@3.930.0': + resolution: {integrity: sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.0.1': + resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.2.1': + resolution: {integrity: sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.1': + resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} + + '@builder.io/jsx-loc-internals@0.0.1': + resolution: {integrity: sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw==} + + '@builder.io/vite-plugin-jsx-loc@0.1.1': + resolution: {integrity: sha512-iAHFkaLBDJBC+EkGO1hF7hnIW2+oKKYVOl8NFAQH//3xeNEzvGdS9tOALRPR+JjR/M5NLyj+FG0VV7WFb1aJmw==} + peerDependencies: + vite: ^4.0.0 || ^5.0.0 + + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.10': + resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.10': + resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.10': + resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.10': + resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.10': + resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.10': + resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.10': + resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.10': + resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.10': + resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.10': + resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.10': + resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.10': + resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.10': + resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.10': + resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.10': + resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.10': + resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.10': + resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.10': + resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.10': + resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.10': + resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.10': + resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.10': + resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.10': + resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.10': + resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.10': + resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.10': + resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.0.2': + resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@medv/finder@4.0.2': + resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==} + + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + + '@rolldown/pluginutils@1.0.0-beta.38': + resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} + + '@rollup/rollup-android-arm-eabi@4.52.4': + resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.4': + resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.4': + resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.4': + resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.4': + resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.4': + resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.52.4': + resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.4': + resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.52.4': + resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.4': + resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.4': + resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.4': + resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.14.0': + resolution: {integrity: sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==} + + '@shikijs/engine-javascript@3.14.0': + resolution: {integrity: sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ==} + + '@shikijs/engine-oniguruma@3.14.0': + resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} + + '@shikijs/langs@3.14.0': + resolution: {integrity: sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==} + + '@shikijs/themes@3.14.0': + resolution: {integrity: sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==} + + '@shikijs/types@3.14.0': + resolution: {integrity: sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@smithy/abort-controller@4.2.0': + resolution: {integrity: sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==} + engines: {node: '>=18.0.0'} + + '@smithy/abort-controller@4.2.5': + resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.1': + resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.3.0': + resolution: {integrity: sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.3': + resolution: {integrity: sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.15.0': + resolution: {integrity: sha512-VJWncXgt+ExNn0U2+Y7UywuATtRYaodGQKFo9mDyh70q+fJGedfrqi2XuKU1BhiLeXgg6RZrW7VEKfeqFhHAJA==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.18.5': + resolution: {integrity: sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.0': + resolution: {integrity: sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.5': + resolution: {integrity: sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.0': + resolution: {integrity: sha512-XE7CtKfyxYiNZ5vz7OvyTf1osrdbJfmUy+rbh+NLQmZumMGvY0mT0Cq1qKSfhrvLtRYzMsOBuRpi10dyI0EBPg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.0': + resolution: {integrity: sha512-U53p7fcrk27k8irLhOwUu+UYnBqsXNLKl1XevOpsxK3y1Lndk8R7CSiZV6FN3fYFuTPuJy5pP6qa/bjDzEkRvA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.0': + resolution: {integrity: sha512-uwx54t8W2Yo9Jr3nVF5cNnkAAnMCJ8Wrm+wDlQY6rY/IrEgZS3OqagtCu/9ceIcZFQ1zVW/zbN9dxb5esuojfA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.0': + resolution: {integrity: sha512-yjM2L6QGmWgJjVu/IgYd6hMzwm/tf4VFX0lm8/SvGbGBwc+aFl3hOzvO/e9IJ2XI+22Tx1Zg3vRpFRs04SWFcg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.0': + resolution: {integrity: sha512-C3jxz6GeRzNyGKhU7oV656ZbuHY93mrfkT12rmjDdZch142ykjn8do+VOkeRNjSGKw01p4g+hdalPYPhmMwk1g==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.1': + resolution: {integrity: sha512-3AvYYbB+Dv5EPLqnJIAgYw/9+WzeBiUYS8B+rU0pHq5NMQMvrZmevUROS4V2GAt0jEOn9viBzPLrZE+riTNd5Q==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.6': + resolution: {integrity: sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.1': + resolution: {integrity: sha512-Os9cg1fTXMwuqbvjemELlf+HB5oEeVyZmYsTbAtDQBmjGyibjmbeeqcaw7xOJLIHrkH/u0wAYabNcN6FRTqMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.0': + resolution: {integrity: sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.5': + resolution: {integrity: sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.0': + resolution: {integrity: sha512-8dELAuGv+UEjtzrpMeNBZc1sJhO8GxFVV/Yh21wE35oX4lOE697+lsMHBoUIFAUuYkTMIeu0EuJSEsH7/8Y+UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.0': + resolution: {integrity: sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.5': + resolution: {integrity: sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.0': + resolution: {integrity: sha512-LFEPniXGKRQArFmDQ3MgArXlClFJMsXDteuQQY8WG1/zzv6gVSo96+qpkuu1oJp4MZsKrwchY0cuAoPKzEbaNA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.0': + resolution: {integrity: sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.5': + resolution: {integrity: sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.3.1': + resolution: {integrity: sha512-JtM4SjEgImLEJVXdsbvWHYiJ9dtuKE8bqLlvkvGi96LbejDL6qnVpVxEFUximFodoQbg0Gnkyff9EKUhFhVJFw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.3.12': + resolution: {integrity: sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.1': + resolution: {integrity: sha512-wXxS4ex8cJJteL0PPQmWYkNi9QKDWZIpsndr0wZI2EL+pSSvA/qqxXU60gBOJoIc2YgtZSWY/PE86qhKCCKP1w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.12': + resolution: {integrity: sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.0': + resolution: {integrity: sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.6': + resolution: {integrity: sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.0': + resolution: {integrity: sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.5': + resolution: {integrity: sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.0': + resolution: {integrity: sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.5': + resolution: {integrity: sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.3.0': + resolution: {integrity: sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.5': + resolution: {integrity: sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.0': + resolution: {integrity: sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.5': + resolution: {integrity: sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.0': + resolution: {integrity: sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.5': + resolution: {integrity: sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.0': + resolution: {integrity: sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.5': + resolution: {integrity: sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.0': + resolution: {integrity: sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.5': + resolution: {integrity: sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.0': + resolution: {integrity: sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.5': + resolution: {integrity: sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.3.0': + resolution: {integrity: sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.0': + resolution: {integrity: sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.0': + resolution: {integrity: sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.5': + resolution: {integrity: sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.7.1': + resolution: {integrity: sha512-WXVbiyNf/WOS/RHUoFMkJ6leEVpln5ojCjNBnzoZeMsnCg3A0BRhLK3WYc4V7PmYcYPZh9IYzzAg9XcNSzYxYQ==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.9.8': + resolution: {integrity: sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.6.0': + resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.9.0': + resolution: {integrity: sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.0': + resolution: {integrity: sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.5': + resolution: {integrity: sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.0': + resolution: {integrity: sha512-H4MAj8j8Yp19Mr7vVtGgi7noJjvjJbsKQJkvNnLlrIFduRFT5jq5Eri1k838YW7rN2g5FTnXpz5ktKVr1KVgPQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.11': + resolution: {integrity: sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.1': + resolution: {integrity: sha512-PuDcgx7/qKEMzV1QFHJ7E4/MMeEjaA7+zS5UNcHCLPvvn59AeZQ0DSDGMpqC2xecfa/1cNGm4l8Ec/VxCuY7Ug==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.14': + resolution: {integrity: sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.0': + resolution: {integrity: sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.5': + resolution: {integrity: sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.0': + resolution: {integrity: sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.5': + resolution: {integrity: sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.0': + resolution: {integrity: sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.5': + resolution: {integrity: sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.0': + resolution: {integrity: sha512-0TD5M5HCGu5diEvZ/O/WquSjhJPasqv7trjoqHyWjNh/FBeBl7a0ztl9uFMOsauYtRfd8jvpzIAQhDHbx+nvZw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.6': + resolution: {integrity: sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.0': + resolution: {integrity: sha512-0Z+nxUU4/4T+SL8BCNN4ztKdQjToNvUYmkF1kXO5T7Yz3Gafzh0HeIG6mrkN8Fz3gn9hSyxuAT+6h4vM+iQSBQ==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + + '@swc/helpers@0.5.18': + resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} + + '@tailwindcss/node@4.1.14': + resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==} + + '@tailwindcss/oxide-android-arm64@4.1.14': + resolution: {integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.14': + resolution: {integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.14': + resolution: {integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.14': + resolution: {integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': + resolution: {integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': + resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': + resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': + resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.14': + resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.14': + resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': + resolution: {integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': + resolution: {integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.14': + resolution: {integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==} + engines: {node: '>= 10'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.1.14': + resolution: {integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/query-core@5.90.2': + resolution: {integrity: sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==} + + '@tanstack/react-query@5.90.2': + resolution: {integrity: sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==} + peerDependencies: + react: ^18 || ^19 + + '@tiptap/core@3.11.0': + resolution: {integrity: sha512-kmS7ZVpHm1EMnW1Wmft9H5ZLM7E0G0NGBx+aGEHGDcNxZBXD2ZUa76CuWjIhOGpwsPbELp684ZdpF2JWoNi4Dg==} + peerDependencies: + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-blockquote@3.11.0': + resolution: {integrity: sha512-0H8WVW6Vn4GJ7sQ6wfyDgUU+DqM8fp62g8N0fFPiEhoYtpIYUmCqGhpKnqYR0tet6ofFa648XmA6n2VX7sugzw==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-bold@3.11.0': + resolution: {integrity: sha512-V/c3XYO09Le9GlBGq1MK4c97Fffi0GADQTbZ+LFoi65nUrAwutn5wYnXBcEyWQI6RmFWVDJTieamqtc4j9teyw==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-bubble-menu@3.11.0': + resolution: {integrity: sha512-P3j9lQ+EZ5Zg/isJzLpCPX7bp7WUBmz8GPs/HPlyMyN2su8LqXntITBZr8IP1JNBlB/wR83k/W0XqdC57mG7cA==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-bullet-list@3.11.0': + resolution: {integrity: sha512-IKdb1C3bHA1sGPiUcntkL+wHebRg71K5+tgaaRnMw0qmtcpcOQb5zhQOSm5bXUsgCk/WgT04dkZPnpn6Gg1PvQ==} + peerDependencies: + '@tiptap/extension-list': ^3.11.0 + + '@tiptap/extension-code-block@3.11.0': + resolution: {integrity: sha512-y01RJVbygDJWYXxZ0SiCYwvUF2X91RANCLSdb8X0qiwVPgNOzsDrrzS/iqoXkiYmM93pJw+ZWelEZxRvxEwsrg==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-code@3.11.0': + resolution: {integrity: sha512-5OpR5O4bveHe1KG9CJsto86NgkuerYq3OLY78vzh9uFCLdv7xgXA2aZYJfRMhbZ7hKsR7hHg1etBJUCk+TKsMg==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-color@3.11.0': + resolution: {integrity: sha512-4H+3nyheow0tBt+q/Pkkm/pR/F6vUAdKZaq1HtWFi7LcFzCYxdB3Si625EparKO3kMvGvULjdd+/zt68NS7Acw==} + peerDependencies: + '@tiptap/extension-text-style': ^3.11.0 + + '@tiptap/extension-document@3.11.0': + resolution: {integrity: sha512-N2G3cwL2Dtur/CgD/byJmFx9T5no6fTO/U462VP3rthQYrRA1AB3TCYqtlwJkmyoxRTNd4qIg4imaPl8ej6Heg==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-dropcursor@3.11.0': + resolution: {integrity: sha512-gW/QMGAyiXGSpO+X/lTeiBQn1Or8T8UVB3y9Cv2Lh6zx0SWU+FA28EH+y6s3fm872reN4dH/9rEvMuJjhU/BEw==} + peerDependencies: + '@tiptap/extensions': ^3.11.0 + + '@tiptap/extension-floating-menu@3.11.0': + resolution: {integrity: sha512-nEHdWZHEJYX1II1oJQ4aeZ8O/Kss4BRbYFXQFGIvPelCfCYEATpUJh3aq3767ARSq40bOWyu+Dcd4SCW0We6Sw==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-gapcursor@3.11.0': + resolution: {integrity: sha512-lXGEZiYX7k/pEFr8BgDE91vqjLTwuf+qhHLTgIpfhbt562nShLPIDj9Vzu3xrR4fwUAMiUNiLyaeInb8j3I4kg==} + peerDependencies: + '@tiptap/extensions': ^3.11.0 + + '@tiptap/extension-hard-break@3.11.0': + resolution: {integrity: sha512-NJEHTj++kFOayQXKSQSi9j9eAG33eSiJqai2pf4U+snW94fmb8cYLUurDmfYRe20O6EzBSX0X3GjVlkOz+5b7A==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-heading@3.11.0': + resolution: {integrity: sha512-4Eo67Yo7vsYLkizcMoGdZAR9aHbC7FFTrqfNEd4Em3ajRi0iNqyWMaI90UCYlitDdRdqFlq/njWrMqBOLUgaWQ==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-horizontal-rule@3.11.0': + resolution: {integrity: sha512-FugFHZG+oiMBV6k42hn9NOA4wRNc2b9UeEIMR+XwEMpWJInV4VwSwDvu8JClgkDo8z7FEnker9e51DZ00CLWqg==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-italic@3.11.0': + resolution: {integrity: sha512-WP6wL2b//8bLVdeUCWOpYA7nUStvrAMMD0nRn0F9CEW+l7vH6El2PZFhHmJ9uqXo5MnyugBpARiwgxfoAlef5w==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-link@3.11.0': + resolution: {integrity: sha512-RoUkGqowVMKLE76KktNOGhzNMyKtwrSDRqeYCe1ODPuOMZvDGexOE8cIuA4A1ODkgN6ji9qE/9Sf8uhpZdH39Q==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-list-item@3.11.0': + resolution: {integrity: sha512-KXTTSBH/T/WW8O1YhK/lVmwlSGh2w2VVucUkMLhgk1VPchahAkn2LfgbgKrCRG/F8M8Jlfvz67iJDo6+bbNqew==} + peerDependencies: + '@tiptap/extension-list': ^3.11.0 + + '@tiptap/extension-list-keymap@3.11.0': + resolution: {integrity: sha512-vm1zGdEqcbQnrGlVXchk1ibmTsyxyfGcGPVWsc4MG+UAFcNfcpAnvCar71BF4RGGPtpzOWdqGkvJENyh0L5/Hw==} + peerDependencies: + '@tiptap/extension-list': ^3.11.0 + + '@tiptap/extension-list@3.11.0': + resolution: {integrity: sha512-4Ane7VCVZ+GFOQNuy2nMP+SoWH7EemC3geTTqvgHm1H0tbSosxLJAVaZ9dF06F35RJmYCm+jLJUhRVd156eCRQ==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/extension-ordered-list@3.11.0': + resolution: {integrity: sha512-kO8GH4w4Xil+qPiHJLAyILdGHF9hCjkhoVtPD8YEfqK6Qx3bZql5FPySCQNs+MU6rLSCCdam8SUPGY/+SCufqA==} + peerDependencies: + '@tiptap/extension-list': ^3.11.0 + + '@tiptap/extension-paragraph@3.11.0': + resolution: {integrity: sha512-hxgjZOXOqstRTWv+QjWJjK23rD5qzIV9ePlhX3imLeq/MgX0aU9VBDaG5SGKbSjaBNQnpLw6+sABJi3CDP6Z5A==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-strike@3.11.0': + resolution: {integrity: sha512-XVP/WMYLrqLBfUsGPu2H9MrOUZLhGUaxtZ3hSRffDi/lsw53x/coZ9eO0FxOB9R7z2ksHWmticIs+0YnKt9LNQ==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-text-style@3.11.0': + resolution: {integrity: sha512-q8RM4gzmdnUHosL65SIJzTTmL29bm+3hNPdloOuJyLd4sTYs2q+cues5mH5/n85HqX3+TvKrfrTVb1Yj62E1NA==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-text@3.11.0': + resolution: {integrity: sha512-ELAYm2BuChzZOqDG9B0k3W6zqM4pwNvXkam28KgHGiT2y7Ni68Rb+NXp16uVR+5zR6hkqnQ/BmJSKzAW59MXpA==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extension-underline@3.11.0': + resolution: {integrity: sha512-D3PsS/84RlQKFjd5eerMIUioC0mNh4yy1RRV/WbXx6ugu+6T+0hT42gNk9Ap8pDsVQZCk0SHfDyBEUFC2KOwKw==} + peerDependencies: + '@tiptap/core': ^3.11.0 + + '@tiptap/extensions@3.11.0': + resolution: {integrity: sha512-g43beA73ZMLezez1st9LEwYrRHZ0FLzlsSlOZKk7sdmtHLmuqWHf4oyb0XAHol1HZIdGv104rYaGNgmQXr1ecQ==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + + '@tiptap/pm@3.11.0': + resolution: {integrity: sha512-plCQDLCZIOc92cizB8NNhBRN0szvYR3cx9i5IXo6v9Xsgcun8KHNcJkesc2AyeqdIs0BtOJZaqQ9adHThz8UDw==} + + '@tiptap/react@3.11.0': + resolution: {integrity: sha512-SDGei/2DjwmhzsxIQNr6dkB6NxLgXZjQ6hF36NfDm4937r5NLrWrNk5tCsoDQiKZ0DHEzuJ6yZM5C7I7LZLB6w==} + peerDependencies: + '@tiptap/core': ^3.11.0 + '@tiptap/pm': ^3.11.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.11.0': + resolution: {integrity: sha512-8kMMYqVSZ2Oqji+mY1o9meTjCRWp4DplFegu7APqDEQRhlb6mBI0wNuazYb7FKJIHJTtf0F6cYglJrxpu9c/fA==} + + '@trpc/client@11.6.0': + resolution: {integrity: sha512-DyWbYk2hd50BaVrXWVkaUnaSwgAF5g/lfBkXtkF1Aqlk6BtSzGUo3owPkgqQO2I5LwWy1+ra9TsSfBBvIZpTwg==} + peerDependencies: + '@trpc/server': 11.6.0 + typescript: '>=5.7.2' + + '@trpc/react-query@11.6.0': + resolution: {integrity: sha512-xljUCzROa23cC89SEd5fwbKiWrGus2NDwtg8zszPlsFvaByWW50Jx6y5sLPXhp/g1FBsEtCInNNhEEL0UCHwGw==} + peerDependencies: + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.6.0 + '@trpc/server': 11.6.0 + react: '>=18.2.0' + react-dom: '>=18.2.0' + typescript: '>=5.7.2' + + '@trpc/server@11.6.0': + resolution: {integrity: sha512-skTso0AWbOZck40jwNeYv++AMZXNWLUWdyk+pB5iVaYmEKTuEeMoPrEudR12VafbEU6tZa8HK3QhBfTYYHDCdg==} + peerDependencies: + typescript: '>=5.7.2' + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/google.maps@3.58.1': + resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.7': + resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@24.7.0': + resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} + + '@types/nodemailer@7.0.4': + resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==} + + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + + '@types/pdfkit@0.17.4': + resolution: {integrity: sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==} + + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.1': + resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.2': + resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + + '@types/send@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/send@1.2.0': + resolution: {integrity: sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==} + + '@types/serve-static@1.15.9': + resolution: {integrity: sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@5.0.4': + resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + add@2.0.6: + resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} + + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.8.12: + resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} + hasBin: true + + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bowser@2.12.1: + resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserslist@4.26.3: + resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001748: + resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==} + + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + + core-js@3.46.0: + resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.11: + resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decode-named-character-reference@1.2.0: + resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dompurify@3.3.0: + resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + drizzle-kit@0.31.5: + resolution: {integrity: sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==} + hasBin: true + + drizzle-orm@0.44.6: + resolution: {integrity: sha512-uy6uarrrEOc9K1u5/uhBFJbdF5VJ5xQ/Yzbecw3eAYOunv5FDeYkR2m8iitocdHBOHbvorviKOW5GVw0U1j4LQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.230: + resolution: {integrity: sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.10: + resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.3.2: + resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==} + engines: {node: '>=6.0.0'} + + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + framer-motion@12.23.22: + resolution: {integrity: sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + + jpeg-exif@1.1.4: + resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jspdf-autotable@5.0.2: + resolution: {integrity: sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==} + peerDependencies: + jspdf: ^2 || ^3 + + jspdf@3.0.3: + resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + katex@0.16.25: + resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} + + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lru.min@1.1.2: + resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + lucide-react@0.453.0: + resolution: {integrity: sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + lucide-react@0.542.0: + resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@16.4.1: + resolution: {integrity: sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + mermaid@11.12.0: + resolution: {integrity: sha512-ZudVx73BwrMJfCFmSSJT84y6u5brEoV8DOItdHomNLz32uBjNrelm7mg95X7g+C6UoQH/W6mBLGDEDv73JdxBg==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + modern-screenshot@4.6.6: + resolution: {integrity: sha512-8tF0xEpe7yx37mK95UcIghSCWYeu628K2hLJl+ZNY2ANmRzYLlRLpquPHAQcL8keF6BoeEzTEw4GrgmUpGuZ8w==} + + motion-dom@12.23.21: + resolution: {integrity: sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + + mysql2@3.15.1: + resolution: {integrity: sha512-WZMIRZstT2MFfouEaDz/AGFnGi1A2GwaDe7XvKTdRJEYiAHbOrh4S3d8KFmQeh11U85G+BFjIvS1Di5alusZsw==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.3: + resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} + engines: {node: '>=12.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.23: + resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} + + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} + engines: {node: '>=6.0.0'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.3: + resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@1.5.0: + resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pdfkit@0.17.2: + resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + png-js@1.0.0: + resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + pnpm@10.18.0: + resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==} + engines: {node: '>=18.12'} + hasBin: true + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + prosemirror-changeset@2.3.1: + resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.2: + resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.1: + resolution: {integrity: sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.10.5: + resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + + prosemirror-view@1.41.3: + resolution: {integrity: sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + react-day-picker@9.11.1: + resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-hook-form@7.64.0: + resolution: {integrity: sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-resizable-panels@3.0.6: + resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + + rehype-harden@1.1.5: + resolution: {integrity: sha512-JrtBj5BVd/5vf3H3/blyJatXJbzQfRT9pJBmjafbTaPouQCAKxHwRyCc7dle9BXQKxv4z1OzZylz/tNamoiG3A==} + + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@4.52.4: + resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shiki@3.14.0: + resolution: {integrity: sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + streamdown@1.4.0: + resolution: {integrity: sha512-ylhDSQ4HpK5/nAH9v7OgIIdGJxlJB2HoYrYkJNGrO8lMpnWuKUcrz/A8xAMwA6eILA27469vIavcOTjmxctrKg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + + style-to-js@1.1.18: + resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} + + style-to-object@1.0.11: + resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + superjson@1.13.3: + resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} + engines: {node: '>=10'} + + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@4.1.14: + resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@7.5.1: + resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} + engines: {node: '>=18'} + + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.1: + resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@7.14.0: + resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite-plugin-manus-runtime@0.0.56: + resolution: {integrity: sha512-BkRMoFzUtT5enXBl+PO0h81KH+4uK4WpLGOp1tvljafb6fp9UX0b1klfxQz9QZFEdB8IPQTEkNzfqv09fHrkMw==} + + vite@5.4.20: + resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.1.9: + resolution: {integrity: sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + wouter@3.7.1: + resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==} + peerDependencies: + react: '>=16.8.0' + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.5.0 + tinyexec: 1.0.1 + + '@antfu/utils@9.3.0': {} + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.901.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.907.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.907.0 + '@aws-sdk/credential-provider-node': 3.907.0 + '@aws-sdk/middleware-bucket-endpoint': 3.901.0 + '@aws-sdk/middleware-expect-continue': 3.901.0 + '@aws-sdk/middleware-flexible-checksums': 3.907.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-location-constraint': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-sdk-s3': 3.907.0 + '@aws-sdk/middleware-ssec': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.907.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/signature-v4-multi-region': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.907.0 + '@aws-sdk/util-user-agent-node': 3.907.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.15.0 + '@smithy/eventstream-serde-browser': 4.2.0 + '@smithy/eventstream-serde-config-resolver': 4.3.0 + '@smithy/eventstream-serde-node': 4.2.0 + '@smithy/fetch-http-handler': 5.3.1 + '@smithy/hash-blob-browser': 4.2.1 + '@smithy/hash-node': 4.2.0 + '@smithy/hash-stream-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/md5-js': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.1 + '@smithy/middleware-retry': 4.4.1 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.0 + '@smithy/util-defaults-mode-node': 4.2.1 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-stream': 4.5.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sesv2@3.940.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.940.0 + '@aws-sdk/credential-provider-node': 3.940.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.940.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/signature-v4-multi-region': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.940.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.5 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.12 + '@smithy/middleware-retry': 4.4.12 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.11 + '@smithy/util-defaults-mode-node': 4.2.14 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.907.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.907.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.907.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.907.0 + '@aws-sdk/util-user-agent-node': 3.907.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.15.0 + '@smithy/fetch-http-handler': 5.3.1 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.1 + '@smithy/middleware-retry': 4.4.1 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.0 + '@smithy/util-defaults-mode-node': 4.2.1 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.940.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.940.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.940.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.940.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.5 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.12 + '@smithy/middleware-retry': 4.4.12 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.11 + '@smithy/util-defaults-mode-node': 4.2.14 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.907.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/xml-builder': 3.901.0 + '@smithy/core': 3.15.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.940.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws-sdk/xml-builder': 3.930.0 + '@smithy/core': 3.18.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/fetch-http-handler': 5.3.1 + '@smithy/node-http-handler': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.5.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/node-http-handler': 4.4.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/util-stream': 4.5.6 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/credential-provider-env': 3.907.0 + '@aws-sdk/credential-provider-http': 3.907.0 + '@aws-sdk/credential-provider-process': 3.907.0 + '@aws-sdk/credential-provider-sso': 3.907.0 + '@aws-sdk/credential-provider-web-identity': 3.907.0 + '@aws-sdk/nested-clients': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-ini@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/credential-provider-env': 3.940.0 + '@aws-sdk/credential-provider-http': 3.940.0 + '@aws-sdk/credential-provider-login': 3.940.0 + '@aws-sdk/credential-provider-process': 3.940.0 + '@aws-sdk/credential-provider-sso': 3.940.0 + '@aws-sdk/credential-provider-web-identity': 3.940.0 + '@aws-sdk/nested-clients': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/nested-clients': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.907.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.907.0 + '@aws-sdk/credential-provider-http': 3.907.0 + '@aws-sdk/credential-provider-ini': 3.907.0 + '@aws-sdk/credential-provider-process': 3.907.0 + '@aws-sdk/credential-provider-sso': 3.907.0 + '@aws-sdk/credential-provider-web-identity': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.940.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.940.0 + '@aws-sdk/credential-provider-http': 3.940.0 + '@aws-sdk/credential-provider-ini': 3.940.0 + '@aws-sdk/credential-provider-process': 3.940.0 + '@aws-sdk/credential-provider-sso': 3.940.0 + '@aws-sdk/credential-provider-web-identity': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.907.0': + dependencies: + '@aws-sdk/client-sso': 3.907.0 + '@aws-sdk/core': 3.907.0 + '@aws-sdk/token-providers': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-sso@3.940.0': + dependencies: + '@aws-sdk/client-sso': 3.940.0 + '@aws-sdk/core': 3.940.0 + '@aws-sdk/token-providers': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/nested-clients': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/nested-clients': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.907.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.5.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@aws/lambda-invoke-store': 0.0.1 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws/lambda-invoke-store': 0.2.1 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/core': 3.15.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.5.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-arn-parser': 3.893.0 + '@smithy/core': 3.18.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-stream': 4.5.6 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@smithy/core': 3.15.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@smithy/core': 3.18.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.907.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.907.0 + '@aws-sdk/middleware-host-header': 3.901.0 + '@aws-sdk/middleware-logger': 3.901.0 + '@aws-sdk/middleware-recursion-detection': 3.901.0 + '@aws-sdk/middleware-user-agent': 3.907.0 + '@aws-sdk/region-config-resolver': 3.901.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-endpoints': 3.901.0 + '@aws-sdk/util-user-agent-browser': 3.907.0 + '@aws-sdk/util-user-agent-node': 3.907.0 + '@smithy/config-resolver': 4.3.0 + '@smithy/core': 3.15.0 + '@smithy/fetch-http-handler': 5.3.1 + '@smithy/hash-node': 4.2.0 + '@smithy/invalid-dependency': 4.2.0 + '@smithy/middleware-content-length': 4.2.0 + '@smithy/middleware-endpoint': 4.3.1 + '@smithy/middleware-retry': 4.4.1 + '@smithy/middleware-serde': 4.2.0 + '@smithy/middleware-stack': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/node-http-handler': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.0 + '@smithy/util-defaults-mode-node': 4.2.1 + '@smithy/util-endpoints': 3.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/nested-clients@3.940.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.940.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.940.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.940.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.5 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.12 + '@smithy/middleware-retry': 4.4.12 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.11 + '@smithy/util-defaults-mode-node': 4.2.14 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/region-config-resolver@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.907.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@aws-sdk/util-format-url': 3.901.0 + '@smithy/middleware-endpoint': 4.3.1 + '@smithy/protocol-http': 5.3.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.907.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/signature-v4': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.940.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.907.0': + dependencies: + '@aws-sdk/core': 3.907.0 + '@aws-sdk/nested-clients': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/token-providers@3.940.0': + dependencies: + '@aws-sdk/core': 3.940.0 + '@aws-sdk/nested-clients': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.936.0': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-endpoints': 3.2.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-endpoints': 3.2.5 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.901.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.907.0': + dependencies: + '@aws-sdk/types': 3.901.0 + '@smithy/types': 4.6.0 + bowser: 2.12.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + bowser: 2.12.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.907.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.907.0 + '@aws-sdk/types': 3.901.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.940.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.940.0 + '@aws-sdk/types': 3.936.0 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.901.0': + dependencies: + '@smithy/types': 4.6.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.930.0': + dependencies: + '@smithy/types': 4.9.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.0.1': {} + + '@aws/lambda-invoke-store@0.2.1': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.4': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.4 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.26.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/traverse@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@braintree/sanitize-url@7.1.1': {} + + '@builder.io/jsx-loc-internals@0.0.1': + dependencies: + '@babel/parser': 7.28.4 + estree-walker: 2.0.2 + magic-string: 0.30.19 + + '@builder.io/vite-plugin-jsx-loc@0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))': + dependencies: + '@builder.io/jsx-loc-internals': 0.0.1 + vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) + + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + + '@chevrotain/utils@11.0.3': {} + + '@date-fns/tz@1.4.1': {} + + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.10.1 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.10': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.10': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.10': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.10': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.10': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.10': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.10': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.10': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.10': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.10': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.10': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.10': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.10': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.10': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.10': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.10': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.10': + optional: true + + '@esbuild/netbsd-arm64@0.25.10': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.10': + optional: true + + '@esbuild/openbsd-arm64@0.25.10': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.10': + optional: true + + '@esbuild/openharmony-arm64@0.25.10': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.10': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.10': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.10': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.10': + optional: true + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/utils@0.2.10': {} + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.0.2': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@antfu/utils': 9.3.0 + '@iconify/types': 2.0.0 + debug: 4.4.3 + globals: 15.15.0 + kolorist: 1.8.0 + local-pkg: 1.1.2 + mlly: 1.8.0 + transitivePeerDependencies: + - supports-color + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@medv/finder@4.0.2': {} + + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.2)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.2 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + + '@radix-ui/rect@1.1.1': {} + + '@remirror/core-constants@3.0.0': {} + + '@rolldown/pluginutils@1.0.0-beta.38': {} + + '@rollup/rollup-android-arm-eabi@4.52.4': + optional: true + + '@rollup/rollup-android-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.4': + optional: true + + '@rollup/rollup-darwin-x64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.4': + optional: true + + '@shikijs/core@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.3 + + '@shikijs/engine-oniguruma@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/themes@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/types@3.14.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@smithy/abort-controller@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/abort-controller@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.1': + dependencies: + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.3.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.3': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + tslib: 2.8.1 + + '@smithy/core@3.15.0': + dependencies: + '@smithy/middleware-serde': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-stream': 4.5.0 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/core@3.18.5': + dependencies: + '@smithy/middleware-serde': 4.2.6 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-stream': 4.5.6 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.5': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.0': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.0': + dependencies: + '@smithy/eventstream-codec': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.1': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.6': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/querystring-builder': 4.2.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.1': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.1 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.5': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.3.1': + dependencies: + '@smithy/core': 3.15.0 + '@smithy/middleware-serde': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/url-parser': 4.2.0 + '@smithy/util-middleware': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.3.12': + dependencies: + '@smithy/core': 3.18.5 + '@smithy/middleware-serde': 4.2.6 + '@smithy/node-config-provider': 4.3.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-middleware': 4.2.5 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.1': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/service-error-classification': 4.2.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-retry': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.12': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/service-error-classification': 4.2.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.0': + dependencies: + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.6': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/shared-ini-file-loader': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.5': + dependencies: + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.3.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/querystring-builder': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.5': + dependencies: + '@smithy/abort-controller': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/querystring-builder': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + + '@smithy/service-error-classification@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + + '@smithy/shared-ini-file-loader@4.3.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/shared-ini-file-loader@4.4.0': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.0 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.5': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.7.1': + dependencies: + '@smithy/core': 3.15.0 + '@smithy/middleware-endpoint': 4.3.1 + '@smithy/middleware-stack': 4.2.0 + '@smithy/protocol-http': 5.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-stream': 4.5.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.9.8': + dependencies: + '@smithy/core': 3.18.5 + '@smithy/middleware-endpoint': 4.3.12 + '@smithy/middleware-stack': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-stream': 4.5.6 + tslib: 2.8.1 + + '@smithy/types@4.6.0': + dependencies: + tslib: 2.8.1 + + '@smithy/types@4.9.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.0': + dependencies: + '@smithy/querystring-parser': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/url-parser@4.2.5': + dependencies: + '@smithy/querystring-parser': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.0': + dependencies: + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.11': + dependencies: + '@smithy/property-provider': 4.2.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.1': + dependencies: + '@smithy/config-resolver': 4.3.0 + '@smithy/credential-provider-imds': 4.2.0 + '@smithy/node-config-provider': 4.3.0 + '@smithy/property-provider': 4.2.0 + '@smithy/smithy-client': 4.7.1 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.14': + dependencies: + '@smithy/config-resolver': 4.4.3 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/smithy-client': 4.9.8 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.0': + dependencies: + '@smithy/node-config-provider': 4.3.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.5': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.0': + dependencies: + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.0': + dependencies: + '@smithy/service-error-classification': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.5': + dependencies: + '@smithy/service-error-classification': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.0': + dependencies: + '@smithy/fetch-http-handler': 5.3.1 + '@smithy/node-http-handler': 4.3.0 + '@smithy/types': 4.6.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.6': + dependencies: + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/node-http-handler': 4.4.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.0': + dependencies: + '@smithy/abort-controller': 4.2.0 + '@smithy/types': 4.6.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + + '@swc/helpers@0.5.18': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.1.14': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + magic-string: 0.30.19 + source-map-js: 1.2.1 + tailwindcss: 4.1.14 + + '@tailwindcss/oxide-android-arm64@4.1.14': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.14': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.14': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.14': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.14': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.14': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.14': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.14': + optional: true + + '@tailwindcss/oxide@4.1.14': + dependencies: + detect-libc: 2.1.2 + tar: 7.5.1 + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-arm64': 4.1.14 + '@tailwindcss/oxide-darwin-x64': 4.1.14 + '@tailwindcss/oxide-freebsd-x64': 4.1.14 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.14 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.14 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.14 + '@tailwindcss/oxide-linux-x64-musl': 4.1.14 + '@tailwindcss/oxide-wasm32-wasi': 4.1.14 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.14 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.14 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.1.14)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.1.14 + + '@tailwindcss/vite@4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))': + dependencies: + '@tailwindcss/node': 4.1.14 + '@tailwindcss/oxide': 4.1.14 + tailwindcss: 4.1.14 + vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) + + '@tanstack/query-core@5.90.2': {} + + '@tanstack/react-query@5.90.2(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.2 + react: 19.2.0 + + '@tiptap/core@3.11.0(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/pm': 3.11.0 + + '@tiptap/extension-blockquote@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-bold@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-bubble-menu@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + optional: true + + '@tiptap/extension-bullet-list@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-code-block@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + + '@tiptap/extension-code@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-color@3.11.0(@tiptap/extension-text-style@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)))': + dependencies: + '@tiptap/extension-text-style': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + + '@tiptap/extension-document@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-dropcursor@3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-floating-menu@3.11.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + optional: true + + '@tiptap/extension-gapcursor@3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-hard-break@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-heading@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-horizontal-rule@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + + '@tiptap/extension-italic@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-link@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-list-keymap@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + + '@tiptap/extension-ordered-list@3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + + '@tiptap/extension-paragraph@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-strike@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-text-style@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-text@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extension-underline@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + + '@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + + '@tiptap/pm@3.11.0': + dependencies: + prosemirror-changeset: 2.3.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.2 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.1 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3) + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + + '@tiptap/react@3.11.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + '@types/react': 19.2.2 + '@types/react-dom': 19.2.1(@types/react@19.2.2) + '@types/use-sync-external-store': 0.0.6 + fast-deep-equal: 3.1.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-floating-menu': 3.11.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.11.0': + dependencies: + '@tiptap/core': 3.11.0(@tiptap/pm@3.11.0) + '@tiptap/extension-blockquote': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-bold': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-bullet-list': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-code': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-code-block': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-document': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-dropcursor': 3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-gapcursor': 3.11.0(@tiptap/extensions@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-hard-break': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-heading': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-horizontal-rule': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-italic': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-link': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/extension-list-item': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-list-keymap': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-ordered-list': 3.11.0(@tiptap/extension-list@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0)) + '@tiptap/extension-paragraph': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-strike': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-text': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extension-underline': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)) + '@tiptap/extensions': 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0) + '@tiptap/pm': 3.11.0 + + '@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@trpc/server': 11.6.0(typescript@5.9.3) + typescript: 5.9.3 + + '@trpc/react-query@11.6.0(@tanstack/react-query@5.90.2(react@19.2.0))(@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.6.0(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + dependencies: + '@tanstack/react-query': 5.90.2(react@19.2.0) + '@trpc/client': 11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/server': 11.6.0(typescript@5.9.3) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + typescript: 5.9.3 + + '@trpc/server@11.6.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.7.0 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.7.0 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 24.7.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.0 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.9 + + '@types/geojson@7946.0.16': {} + + '@types/google.maps@3.58.1': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/http-errors@2.0.5': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.7.0 + + '@types/katex@0.16.7': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/mime@1.3.5': {} + + '@types/ms@2.1.0': {} + + '@types/multer@2.0.0': + dependencies: + '@types/express': 4.17.21 + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 24.7.0 + form-data: 4.0.4 + + '@types/node@14.18.63': {} + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@24.7.0': + dependencies: + undici-types: 7.14.0 + + '@types/nodemailer@7.0.4': + dependencies: + '@aws-sdk/client-sesv2': 3.940.0 + '@types/node': 24.7.0 + transitivePeerDependencies: + - aws-crt + + '@types/pako@2.0.4': {} + + '@types/pdfkit@0.17.4': + dependencies: + '@types/node': 24.7.0 + + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.7.0 + + '@types/qs@6.14.0': {} + + '@types/raf@3.4.3': + optional: true + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.1(@types/react@19.2.2)': + dependencies: + '@types/react': 19.2.2 + + '@types/react@19.2.2': + dependencies: + csstype: 3.1.3 + + '@types/send@0.17.5': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.7.0 + + '@types/send@1.2.0': + dependencies: + '@types/node': 24.7.0 + + '@types/serve-static@1.15.9': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.7.0 + '@types/send': 0.17.5 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))': + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) + '@rolldown/pluginutils': 1.0.0-beta.38 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + vite: 5.4.20(@types/node@24.7.0)(lightningcss@1.30.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.19 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn@8.15.0: {} + + add@2.0.6: {} + + adler-32@1.3.1: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + append-field@1.0.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-flatten@1.1.1: {} + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.21(postcss@8.5.6): + dependencies: + browserslist: 4.26.3 + caniuse-lite: 1.0.30001748 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + aws-ssl-profiles@1.1.2: {} + + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + base64-arraybuffer@1.0.2: + optional: true + + base64-js@0.0.8: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.8.12: {} + + bcryptjs@3.0.3: {} + + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bowser@2.12.1: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserslist@4.26.3: + dependencies: + baseline-browser-mapping: 2.8.12 + caniuse-lite: 1.0.30001748 + electron-to-chromium: 1.5.230 + node-releases: 2.0.23 + update-browserslist-db: 1.1.3(browserslist@4.26.3) + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@5.3.1: {} + + caniuse-lite@1.0.30001748: {} + + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.28.4 + '@types/raf': 3.4.3 + core-js: 3.46.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + + ccount@2.0.1: {} + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + check-error@2.1.1: {} + + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.17.21 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + + chownr@3.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + clone@2.1.2: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + codepage@1.15.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + confbox@0.1.8: {} + + confbox@0.2.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + cookie@1.0.2: {} + + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + + core-js@3.46.0: + optional: true + + core-util-is@1.0.3: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + crelt@1.0.6: {} + + crypto-js@4.2.0: {} + + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + + cssesc@3.0.0: {} + + csstype@3.1.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.0: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.0 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.11: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.21 + + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + + dayjs@1.11.18: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decimal.js-light@2.5.1: {} + + decode-named-character-reference@1.2.0: + dependencies: + character-entities: 2.0.2 + + deep-eql@5.0.2: {} + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dfa@1.2.0: {} + + dijkstrajs@1.0.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + + dompurify@3.3.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dotenv@17.2.3: {} + + drizzle-kit@0.31.5: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.10 + esbuild-register: 3.6.0(esbuild@0.25.10) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.44.6(mysql2@3.15.1): + optionalDependencies: + mysql2: 3.15.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.230: {} + + embla-carousel-react@8.6.0(react@19.2.0): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.0 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild-register@3.6.0(esbuild@0.25.10): + dependencies: + debug: 4.4.3 + esbuild: 0.25.10 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.10: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.10 + '@esbuild/android-arm': 0.25.10 + '@esbuild/android-arm64': 0.25.10 + '@esbuild/android-x64': 0.25.10 + '@esbuild/darwin-arm64': 0.25.10 + '@esbuild/darwin-x64': 0.25.10 + '@esbuild/freebsd-arm64': 0.25.10 + '@esbuild/freebsd-x64': 0.25.10 + '@esbuild/linux-arm': 0.25.10 + '@esbuild/linux-arm64': 0.25.10 + '@esbuild/linux-ia32': 0.25.10 + '@esbuild/linux-loong64': 0.25.10 + '@esbuild/linux-mips64el': 0.25.10 + '@esbuild/linux-ppc64': 0.25.10 + '@esbuild/linux-riscv64': 0.25.10 + '@esbuild/linux-s390x': 0.25.10 + '@esbuild/linux-x64': 0.25.10 + '@esbuild/netbsd-arm64': 0.25.10 + '@esbuild/netbsd-x64': 0.25.10 + '@esbuild/openbsd-arm64': 0.25.10 + '@esbuild/openbsd-x64': 0.25.10 + '@esbuild/openharmony-arm64': 0.25.10 + '@esbuild/sunos-x64': 0.25.10 + '@esbuild/win32-arm64': 0.25.10 + '@esbuild/win32-ia32': 0.25.10 + '@esbuild/win32-x64': 0.25.10 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.18 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.5 + unzipper: 0.10.14 + uuid: 8.3.2 + + expect-type@1.2.2: {} + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.7: {} + + extend@3.0.2: {} + + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + + fast-deep-equal@3.1.3: {} + + fast-equals@5.3.2: {} + + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + follow-redirects@1.15.11: {} + + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.18 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + + form-data-encoder@1.7.2: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + + forwarded@0.2.0: {} + + frac@1.1.2: {} + + fraction.js@4.3.7: {} + + framer-motion@12.23.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + motion-dom: 12.23.21 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@15.15.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + hachure-fill@0.5.2: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.18 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + html-url-attributes@3.0.1: {} + + html-void-elements@3.0.0: {} + + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-parser@0.2.4: {} + + input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + iobuffer@5.4.0: {} + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + is-property@1.0.2: {} + + is-what@4.1.16: {} + + isarray@1.0.0: {} + + jiti@2.6.1: {} + + jose@6.1.0: {} + + jpeg-exif@1.1.4: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jspdf-autotable@5.0.2(jspdf@3.0.3): + dependencies: + jspdf: 3.0.3 + + jspdf@3.0.3: + dependencies: + '@babel/runtime': 7.28.4 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.46.0 + dompurify: 3.3.0 + html2canvas: 1.4.1 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + katex@0.16.25: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + kolorist@1.8.0: {} + + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lightningcss-darwin-arm64@1.30.1: + optional: true + + lightningcss-darwin-x64@1.30.1: + optional: true + + lightningcss-freebsd-x64@1.30.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.1: + optional: true + + lightningcss-linux-arm64-gnu@1.30.1: + optional: true + + lightningcss-linux-arm64-musl@1.30.1: + optional: true + + lightningcss-linux-x64-gnu@1.30.1: + optional: true + + lightningcss-linux-x64-musl@1.30.1: + optional: true + + lightningcss-win32-arm64-msvc@1.30.1: + optional: true + + lightningcss-win32-x64-msvc@1.30.1: + optional: true + + lightningcss@1.30.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 + + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + + listenercount@1.0.1: {} + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash-es@4.17.21: {} + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnil@4.0.0: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.isundefined@3.0.1: {} + + lodash.once@4.1.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.21: {} + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@7.18.3: {} + + lru.min@1.1.2: {} + + lucide-react@0.453.0(react@19.2.0): + dependencies: + react: 19.2.0 + + lucide-react@0.542.0(react@19.2.0): + dependencies: + react: 19.2.0 + + magic-string@0.30.19: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.4: {} + + marked@16.4.1: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + mermaid@11.12.0: + dependencies: + '@braintree/sanitize-url': 7.1.1 + '@iconify/utils': 3.0.2 + '@mermaid-js/parser': 0.6.3 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.11 + dayjs: 1.11.18 + dompurify: 3.3.0 + katex: 0.16.25 + khroma: 2.1.0 + lodash-es: 4.17.21 + marked: 16.4.1 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + + methods@1.1.2: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.7 + devlop: 1.1.0 + katex: 0.16.25 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.2.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + + mitt@3.0.1: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + + modern-screenshot@4.6.6: {} + + motion-dom@12.23.21: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mysql2@3.15.1: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.0 + long: 5.3.2 + lru.min: 1.1.2 + named-placeholders: 1.1.3 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.3: + dependencies: + lru-cache: 7.18.3 + + nanoid@3.3.11: {} + + nanoid@5.1.6: {} + + negotiator@0.6.3: {} + + next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.23: {} + + nodemailer@7.0.11: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.3: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.0.1 + regex-recursion: 6.0.2 + + openai@4.104.0(zod@4.1.12): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + optionalDependencies: + zod: 4.1.12 + transitivePeerDependencies: + - encoding + + orderedmap@2.1.1: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-manager-detector@1.5.0: {} + + pako@0.2.9: {} + + pako@1.0.11: {} + + pako@2.1.0: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.2.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-data-parser@0.1.0: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-to-regexp@0.1.12: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pdfkit@0.17.2: + dependencies: + crypto-js: 4.2.0 + fontkit: 2.0.4 + jpeg-exif: 1.1.4 + linebreak: 1.1.0 + png-js: 1.0.0 + + performance-now@2.1.0: + optional: true + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.7 + pathe: 2.0.3 + + png-js@1.0.0: {} + + pngjs@5.0.0: {} + + pnpm@10.18.0: {} + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.6.2: {} + + process-nextick-args@2.0.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@6.5.0: {} + + property-information@7.1.0: {} + + prosemirror-changeset@2.3.1: + dependencies: + prosemirror-transform: 1.10.5 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.3 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.2: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + + prosemirror-tables@1.8.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.3): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.3 + + prosemirror-transform@1.10.5: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.3: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + punycode.js@2.3.1: {} + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.11: {} + + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-day-picker@9.11.1(react@19.2.0): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.0 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-hook-form@7.64.0(react@19.2.0): + dependencies: + react: 19.2.0 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.2 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.0 + react: 19.2.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + react-remove-scroll@2.7.1(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.2)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.2)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.2)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.2)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + + react-resizable-panels@3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react-smooth@4.0.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + fast-equals: 5.3.2 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-transition-group: 4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + + react-style-singleton@2.2.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + react-transition-group@4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react@19.2.0: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + regenerator-runtime@0.13.11: + optional: true + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + + regexparam@3.0.0: {} + + rehype-harden@1.1.5: {} + + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.7 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.25 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restructure@3.0.2: {} + + rgbcolor@1.0.1: + optional: true + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + robust-predicates@3.0.2: {} + + rollup@4.52.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.4 + '@rollup/rollup-android-arm64': 4.52.4 + '@rollup/rollup-darwin-arm64': 4.52.4 + '@rollup/rollup-darwin-x64': 4.52.4 + '@rollup/rollup-freebsd-arm64': 4.52.4 + '@rollup/rollup-freebsd-x64': 4.52.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 + '@rollup/rollup-linux-arm-musleabihf': 4.52.4 + '@rollup/rollup-linux-arm64-gnu': 4.52.4 + '@rollup/rollup-linux-arm64-musl': 4.52.4 + '@rollup/rollup-linux-loong64-gnu': 4.52.4 + '@rollup/rollup-linux-ppc64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-musl': 4.52.4 + '@rollup/rollup-linux-s390x-gnu': 4.52.4 + '@rollup/rollup-linux-x64-gnu': 4.52.4 + '@rollup/rollup-linux-x64-musl': 4.52.4 + '@rollup/rollup-openharmony-arm64': 4.52.4 + '@rollup/rollup-win32-arm64-msvc': 4.52.4 + '@rollup/rollup-win32-ia32-msvc': 4.52.4 + '@rollup/rollup-win32-x64-gnu': 4.52.4 + '@rollup/rollup-win32-x64-msvc': 4.52.4 + fsevents: 2.3.3 + + rope-sequence@1.3.4: {} + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + seq-queue@0.0.5: {} + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shiki@3.14.0: + dependencies: + '@shikijs/core': 3.14.0 + '@shikijs/engine-javascript': 3.14.0 + '@shikijs/engine-oniguruma': 3.14.0 + '@shikijs/langs': 3.14.0 + '@shikijs/themes': 3.14.0 + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + sqlstring@2.3.3: {} + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + + stackback@0.0.2: {} + + stackblur-canvas@2.7.0: + optional: true + + statuses@2.0.1: {} + + std-env@3.9.0: {} + + streamdown@1.4.0(@types/react@19.2.2)(react@19.2.0): + dependencies: + clsx: 2.1.1 + katex: 0.16.25 + lucide-react: 0.542.0(react@19.2.0) + marked: 16.4.1 + mermaid: 11.12.0 + react: 19.2.0 + react-markdown: 10.1.0(@types/react@19.2.2)(react@19.2.0) + rehype-harden: 1.1.5 + rehype-katex: 7.0.1 + rehype-raw: 7.0.0 + remark-gfm: 4.0.1 + remark-math: 6.0.0 + shiki: 3.14.0 + tailwind-merge: 3.3.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + + streamsearch@1.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strnum@2.1.1: {} + + style-to-js@1.1.18: + dependencies: + style-to-object: 1.0.11 + + style-to-object@1.0.11: + dependencies: + inline-style-parser: 0.2.4 + + stylis@4.3.6: {} + + superjson@1.13.3: + dependencies: + copy-anything: 3.0.5 + + svg-pathdata@6.0.3: + optional: true + + tailwind-merge@3.3.1: {} + + tailwindcss-animate@1.0.7(tailwindcss@4.1.14): + dependencies: + tailwindcss: 4.1.14 + + tailwindcss@4.1.14: {} + + tapable@2.3.0: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@7.5.1: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + + tiny-inflate@1.0.3: {} + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.1: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tmp@0.2.5: {} + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + traverse@0.3.9: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-dedent@2.2.0: {} + + tslib@2.8.1: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.10 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + + tw-animate-css@1.4.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.1: {} + + undici-types@5.26.5: {} + + undici-types@7.14.0: {} + + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unpipe@1.0.0: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + update-browserslist-db@1.1.3(browserslist@4.26.3): + dependencies: + browserslist: 4.26.3 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.2 + + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + + uuid@11.1.0: {} + + uuid@8.3.2: {} + + vary@1.1.2: {} + + vaul@1.1.2(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite-node@2.1.9(@types/node@24.7.0)(lightningcss@1.30.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.20(@types/node@24.7.0)(lightningcss@1.30.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-plugin-manus-runtime@0.0.56: + dependencies: + '@medv/finder': 4.0.2 + clsx: 2.1.1 + modern-screenshot: 4.6.6 + nanoid: 5.1.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tailwind-merge: 3.3.1 + + vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.52.4 + optionalDependencies: + '@types/node': 24.7.0 + fsevents: 2.3.3 + lightningcss: 1.30.1 + + vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6): + dependencies: + esbuild: 0.25.10 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.7.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + tsx: 4.20.6 + + vitest@2.1.9(@types/node@24.7.0)(lightningcss@1.30.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.19 + pathe: 1.1.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.20(@types/node@24.7.0)(lightningcss@1.30.1) + vite-node: 2.1.9(@types/node@24.7.0)(lightningcss@1.30.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.7.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + + w3c-keyname@2.2.8: {} + + web-namespaces@2.0.1: {} + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-module@2.0.1: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wmf@1.0.2: {} + + word@0.3.0: {} + + wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.0): + dependencies: + mitt: 3.0.1 + react: 19.2.0 + regexparam: 3.0.0 + use-sync-external-store: 1.6.0(react@19.2.0) + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@4.0.3: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zod@4.1.12: {} + + zwitch@2.0.4: {} diff --git a/deployment-package/scripts/create-admin.sh b/deployment-package/scripts/create-admin.sh deleted file mode 100755 index 2b7d4d7..0000000 --- a/deployment-package/scripts/create-admin.sh +++ /dev/null @@ -1,229 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de création de l'utilisateur administrateur (version simplifiée) -# Utilise un hash bcrypt pré-calculé pour éviter les dépendances Node.js -############################################################################### - -set -e - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Création de l'utilisateur administrateur ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# Vérifier que le fichier .env existe -if [ ! -f ".env" ]; then - echo -e "${RED}[ERREUR]${NC} Fichier .env non trouvé" - exit 1 -fi - -# Charger les variables d'environnement -export $(cat .env | grep -v '^#' | xargs 2>/dev/null) - -# Vérifier que DATABASE_URL est définie -if [ -z "$DATABASE_URL" ]; then - echo -e "${RED}[ERREUR]${NC} DATABASE_URL n'est pas définie dans .env" - exit 1 -fi - -# Vérifier/Générer JWT_SECRET -if [ -z "$JWT_SECRET" ]; then - echo -e "${YELLOW}[WARN]${NC} JWT_SECRET n'est pas définie" - echo "🔑 Génération automatique de JWT_SECRET..." - NEW_JWT_SECRET=$(openssl rand -base64 32) - echo "JWT_SECRET=$NEW_JWT_SECRET" >> .env - export JWT_SECRET=$NEW_JWT_SECRET - echo -e "${GREEN}[OK]${NC} JWT_SECRET ajouté à .env" - echo "" -fi - -# Extraire les informations de connexion de DATABASE_URL -DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - -# Afficher les informations (masquer le mot de passe) -echo "📋 Configuration de la base de données:" -echo " Hôte: $DB_HOST:$DB_PORT" -echo " Base: $DB_NAME" -echo " Utilisateur: $DB_USER" -echo "" - -# Vérifier MySQL -if ! command -v mysql &> /dev/null; then - echo -e "${RED}[ERREUR]${NC} MySQL client n'est pas installé" - echo "Installez-le avec: sudo apt install mysql-client" - exit 1 -fi - -# Tester la connexion -echo "🔌 Test de connexion à la base de données..." -if ! mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -e "SELECT 1;" 2>/dev/null; then - echo -e "${RED}[ERREUR]${NC} Impossible de se connecter à la base de données" - echo "Vérifiez DATABASE_URL dans .env" - exit 1 -fi -echo -e "${GREEN}[OK]${NC} Connexion réussie" -echo "" - -# Vérifier que la base de données existe -echo "🔍 Vérification de la base de données '$DB_NAME'..." -if ! mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -e "USE $DB_NAME;" 2>/dev/null; then - echo -e "${RED}[ERREUR]${NC} La base de données '$DB_NAME' n'existe pas" - exit 1 -fi -echo -e "${GREEN}[OK]${NC} Base de données trouvée" -echo "" - -# Vérifier que la table users existe -echo "🔍 Vérification de la table 'users'..." -TABLE_EXISTS=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -se "SHOW TABLES LIKE 'users';" 2>/dev/null) - -if [ -z "$TABLE_EXISTS" ]; then - echo -e "${RED}[ERREUR]${NC} La table 'users' n'existe pas" - echo "Exécutez d'abord: pnpm db:push" - exit 1 -fi -echo -e "${GREEN}[OK]${NC} Table 'users' trouvée" -echo "" - -# Informations de l'utilisateur -USERNAME="adminServFormation" -PASSWORD="Itinova69!" -EMAIL="admin@formation.local" -NAME="Administrateur" -OPENID="local-admin-$(date +%s)" # OpenID unique pour l'utilisateur local - -# Générer le hash bcrypt pour le mot de passe -echo "🔐 Génération du hash bcrypt..." - -# Vérifier que bcryptjs est installé -if [ ! -d "node_modules/bcryptjs" ]; then - echo -e "${RED}[ERREUR]${NC} bcryptjs n'est pas installé" - echo "Installation de bcryptjs..." - pnpm install bcryptjs -fi - -# Générer le hash avec Node.js -PASSWORD_HASH=$(node -p "require('bcryptjs').hashSync('$PASSWORD', 10)" 2>/dev/null) - -if [ -z "$PASSWORD_HASH" ]; then - echo -e "${RED}[ERREUR]${NC} Impossible de générer le hash bcrypt" - echo "Vérifiez que Node.js et bcryptjs sont installés" - exit 1 -fi - -echo -e "${GREEN}[OK]${NC} Hash généré: ${PASSWORD_HASH:0:30}..." -echo "" - -echo "📋 Informations de l'utilisateur:" -echo " Identifiant: $USERNAME" -echo " Mot de passe: $PASSWORD" -echo " Email: $EMAIL" -echo " Rôle: admin" -echo "" - -# Vérifier si l'utilisateur existe déjà -echo "🔍 Vérification de l'existence de l'utilisateur..." -USER_COUNT=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -se "SELECT COUNT(*) FROM users WHERE username='$USERNAME';" 2>/dev/null) - -if [ "$USER_COUNT" = "1" ]; then - echo -e "${YELLOW}[INFO]${NC} L'utilisateur existe déjà" - echo "" - read -p "Voulez-vous réinitialiser le mot de passe? (y/N) " -n 1 -r - echo "" - - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Opération annulée" - exit 0 - fi - - echo "🔄 Réinitialisation du mot de passe..." - - mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME <&1 -UPDATE users -SET - password = '$PASSWORD_HASH', - email = '$EMAIL', - name = '$NAME', - role = 'admin', - isActive = 1, - loginMethod = 'local', - updatedAt = NOW() -WHERE username = '$USERNAME'; -EOF - - if [ $? -eq 0 ]; then - echo -e "${GREEN}[OK]${NC} Mot de passe réinitialisé" - else - echo -e "${RED}[ERREUR]${NC} Échec de la réinitialisation" - exit 1 - fi -else - echo -e "${YELLOW}[INFO]${NC} L'utilisateur n'existe pas" - echo "➕ Création de l'utilisateur..." - - # Créer l'utilisateur avec affichage des erreurs - mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME <&1 -INSERT INTO users (openId, username, password, email, name, role, isActive, loginMethod, createdAt, updatedAt) -VALUES ( - '$OPENID', - '$USERNAME', - '$PASSWORD_HASH', - '$EMAIL', - '$NAME', - 'admin', - 1, - 'local', - NOW(), - NOW() -); -EOF - - if [ $? -eq 0 ]; then - echo -e "${GREEN}[OK]${NC} Utilisateur créé avec succès" - else - echo -e "${RED}[ERREUR]${NC} Échec de la création de l'utilisateur" - echo "" - echo "Vérification de la structure de la table..." - mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -e "DESCRIBE users;" 2>&1 - exit 1 - fi -fi - -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Opération terminée avec succès! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "🔑 Identifiants de connexion:" -echo " Identifiant: $USERNAME" -echo " Mot de passe: $PASSWORD" -echo "" -echo "⚠️ IMPORTANT: Changez le mot de passe après la première connexion" -echo "" - -# Vérifier et redémarrer le service si disponible -if command -v systemctl &> /dev/null; then - if systemctl list-unit-files | grep -q "formation-manager"; then - echo "🔄 Redémarrage du service formation-manager..." - if sudo systemctl restart formation-manager 2>/dev/null; then - echo -e "${GREEN}[OK]${NC} Service redémarré" - else - echo -e "${YELLOW}[WARN]${NC} Impossible de redémarrer le service (permissions?)" - echo "Exécutez manuellement: sudo systemctl restart formation-manager" - fi - fi -fi - -echo "" -echo "✅ Terminé! Vous pouvez maintenant vous connecter." diff --git a/deployment-package/scripts/deploy-update-manual.sh b/deployment-package/scripts/deploy-update-manual.sh deleted file mode 100755 index b337627..0000000 --- a/deployment-package/scripts/deploy-update-manual.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash - -# Script de mise à jour manuel pour le serveur VPS (sans Git) -# Formation Manager Itinova -# Version: dcb709b3 - -set -e # Arrêter en cas d'erreur - -# Charger les variables d'environnement -if [ -f .env ]; then - export $(grep -v '^#' .env | xargs) -fi - -echo "=========================================" -echo "Mise à jour Formation Manager Itinova" -echo "Mode manuel (sans Git)" -echo "=========================================" -echo "" - -# Couleurs pour les messages -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Vérifier que nous sommes dans le bon répertoire -if [ ! -f "package.json" ]; then - echo -e "${RED}Erreur: package.json non trouvé. Êtes-vous dans le bon répertoire ?${NC}" - exit 1 -fi - -echo -e "${YELLOW}1. Sauvegarde de la base de données...${NC}" -# Créer un répertoire de backup s'il n'existe pas -mkdir -p backups -BACKUP_FILE="backups/db-backup-$(date +%Y%m%d-%H%M%S).sql" - -# Extraire les informations de connexion depuis DATABASE_URL -if [ -z "$DATABASE_URL" ]; then - echo -e "${RED}Erreur: DATABASE_URL n'est pas définie${NC}" - exit 1 -fi - -# Parser DATABASE_URL -DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - -echo "Sauvegarde de la base de données $DB_NAME..." -mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_FILE" -echo -e "${GREEN}✓ Sauvegarde créée: $BACKUP_FILE${NC}" -echo "" - -echo -e "${YELLOW}2. Arrêt du serveur...${NC}" -pm2 stop formation-manager || echo "Le serveur n'était pas démarré" -echo -e "${GREEN}✓ Serveur arrêté${NC}" -echo "" - -echo -e "${YELLOW}3. Les fichiers ont été transférés via SFTP${NC}" -echo "Assurez-vous d'avoir transféré tous les fichiers modifiés avant de continuer." -read -p "Appuyez sur Entrée pour continuer..." -echo "" - -echo -e "${YELLOW}4. Installation des dépendances...${NC}" -pnpm install -echo -e "${GREEN}✓ Dépendances installées${NC}" -echo "" - -echo -e "${YELLOW}5. Application des migrations de base de données...${NC}" -pnpm db:push -echo -e "${GREEN}✓ Migrations appliquées${NC}" -echo "" - -echo -e "${YELLOW}6. Compilation du projet...${NC}" -pnpm build -echo -e "${GREEN}✓ Projet compilé${NC}" -echo "" - -echo -e "${YELLOW}7. Redémarrage du serveur...${NC}" -pm2 restart formation-manager || pm2 start npm --name "formation-manager" -- start -echo -e "${GREEN}✓ Serveur redémarré${NC}" -echo "" - -echo -e "${YELLOW}8. Vérification du statut...${NC}" -pm2 status formation-manager -echo "" - -echo -e "${GREEN}=========================================${NC}" -echo -e "${GREEN}Mise à jour terminée avec succès !${NC}" -echo -e "${GREEN}=========================================${NC}" -echo "" -echo "Sauvegarde de la base de données: $BACKUP_FILE" -echo "Pour consulter les logs: pm2 logs formation-manager" -echo "Pour arrêter le serveur: pm2 stop formation-manager" -echo "" diff --git a/deployment-package/scripts/deploy-update-targz.sh b/deployment-package/scripts/deploy-update-targz.sh deleted file mode 100755 index c6d49a9..0000000 --- a/deployment-package/scripts/deploy-update-targz.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -# Script mise à jour avec archive .tar.gz -set -e - -# Charger les variables d'environnement -if [ -f .env ]; then - export $(grep -v '^#' .env | xargs) -fi -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' -echo "=========================================" -echo "Mise à jour Formation Manager Itinova" -echo "=========================================" -if [ -z "$1" ]; then - echo -e "${RED}Usage: $0 /chemin/vers/archive.tar.gz${NC}" - exit 1 -fi -ARCHIVE_PATH="$1" -PROJECT_DIR=$(pwd) -TEMP_DIR="/tmp/formation-update-$$" -echo -e "${YELLOW}1. Sauvegarde BDD...${NC}" -mkdir -p backups -BACKUP_FILE="backups/db-backup-$(date +%Y%m%d-%H%M%S).sql" -DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') -mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" --no-tablespaces "$DB_NAME" > "$BACKUP_FILE" -echo -e "${GREEN}✓ Backup OK${NC}" -echo -e "${YELLOW}2. Arrêt serveur...${NC}" -pm2 stop formation-manager || true -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}3. Extraction...${NC}" -mkdir -p "$TEMP_DIR" -tar -xzf "$ARCHIVE_PATH" -C "$TEMP_DIR" -if [ -d "$TEMP_DIR/formation-manager-itinova" ]; then - EXTRACTED_DIR="$TEMP_DIR/formation-manager-itinova" -else - EXTRACTED_DIR="$TEMP_DIR" -fi -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}4. Copie fichiers...${NC}" -rsync -av --exclude='node_modules' --exclude='dist' --exclude='backups' "$EXTRACTED_DIR/" "$PROJECT_DIR/" -rm -rf "$TEMP_DIR" -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}5. pnpm install...${NC}" -pnpm install -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}6. pnpm db:push...${NC}" -pnpm db:push -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}7. pnpm build...${NC}" -pnpm build -echo -e "${GREEN}✓ OK${NC}" -echo -e "${YELLOW}8. Redémarrage...${NC}" -pm2 restart formation-manager || pm2 start npm --name "formation-manager" -- start -echo -e "${GREEN}✓ OK${NC}" -pm2 status formation-manager -echo -e "${GREEN}Mise à jour terminée !${NC}" diff --git a/deployment-package/scripts/deploy-update.sh b/deployment-package/scripts/deploy-update.sh deleted file mode 100755 index 77e6da8..0000000 --- a/deployment-package/scripts/deploy-update.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/bash - -# Script de mise à jour pour le serveur VPS -# Formation Manager Itinova -# Version: d0d9d870 - -set -e # Arrêter en cas d'erreur - -# Charger les variables d'environnement -if [ -f .env ]; then - export $(grep -v '^#' .env | xargs) -fi - -echo "=========================================" -echo "Mise à jour Formation Manager Itinova" -echo "=========================================" -echo "" - -# Couleurs pour les messages -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Vérifier que nous sommes dans le bon répertoire -if [ ! -f "package.json" ]; then - echo -e "${RED}Erreur: package.json non trouvé. Êtes-vous dans le bon répertoire ?${NC}" - exit 1 -fi - -echo -e "${YELLOW}1. Sauvegarde de la base de données...${NC}" -# Créer un répertoire de backup s'il n'existe pas -mkdir -p backups -BACKUP_FILE="backups/db-backup-$(date +%Y%m%d-%H%M%S).sql" - -# Extraire les informations de connexion depuis DATABASE_URL -# Format: mysql://user:password@host:port/database -if [ -z "$DATABASE_URL" ]; then - echo -e "${RED}Erreur: DATABASE_URL n'est pas définie${NC}" - exit 1 -fi - -# Parser DATABASE_URL -DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - -echo "Sauvegarde de la base de données $DB_NAME..." -mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_FILE" -echo -e "${GREEN}✓ Sauvegarde créée: $BACKUP_FILE${NC}" -echo "" - -echo -e "${YELLOW}2. Arrêt du serveur...${NC}" -pm2 stop formation-manager || echo "Le serveur n'était pas démarré" -echo -e "${GREEN}✓ Serveur arrêté${NC}" -echo "" - -echo -e "${YELLOW}3. Récupération des dernières modifications...${NC}" -git fetch origin -git checkout main -git pull origin main -echo -e "${GREEN}✓ Code mis à jour${NC}" -echo "" - -echo -e "${YELLOW}4. Installation des dépendances...${NC}" -pnpm install -echo -e "${GREEN}✓ Dépendances installées${NC}" -echo "" - -echo -e "${YELLOW}5. Application des migrations de base de données...${NC}" -pnpm db:push -echo -e "${GREEN}✓ Migrations appliquées${NC}" -echo "" - -echo -e "${YELLOW}6. Compilation du projet...${NC}" -pnpm build -echo -e "${GREEN}✓ Projet compilé${NC}" -echo "" - -echo -e "${YELLOW}7. Redémarrage du serveur...${NC}" -pm2 restart formation-manager || pm2 start npm --name "formation-manager" -- start -echo -e "${GREEN}✓ Serveur redémarré${NC}" -echo "" - -echo -e "${YELLOW}8. Vérification du statut...${NC}" -pm2 status formation-manager -echo "" - -echo -e "${GREEN}=========================================${NC}" -echo -e "${GREEN}Mise à jour terminée avec succès !${NC}" -echo -e "${GREEN}=========================================${NC}" -echo "" -echo "Sauvegarde de la base de données: $BACKUP_FILE" -echo "Pour consulter les logs: pm2 logs formation-manager" -echo "Pour arrêter le serveur: pm2 stop formation-manager" -echo "" diff --git a/deployment-package/scripts/deploy.sh b/deployment-package/scripts/deploy.sh deleted file mode 100755 index bfa7d9a..0000000 --- a/deployment-package/scripts/deploy.sh +++ /dev/null @@ -1,273 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de déploiement - Gestion des Formations Manager Itinova -# Version: 1.0.0 -# Description: Script automatisé pour déployer l'application en production -############################################################################### - -set -e # Arrêter en cas d'erreur - -# Couleurs pour les messages -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Fonction pour afficher les messages -log_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Fonction pour vérifier les prérequis -check_prerequisites() { - log_info "Vérification des prérequis..." - - # Vérifier Node.js - if ! command -v node &> /dev/null; then - log_error "Node.js n'est pas installé" - exit 1 - fi - - NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) - if [ "$NODE_VERSION" -lt 18 ]; then - log_error "Node.js version 18+ est requis (version actuelle: $(node -v))" - exit 1 - fi - log_info "Node.js version: $(node -v) ✓" - - # Vérifier pnpm - if ! command -v pnpm &> /dev/null; then - log_warn "pnpm n'est pas installé. Installation..." - npm install -g pnpm - fi - log_info "pnpm version: $(pnpm -v) ✓" - - # Vérifier MySQL - if ! command -v mysql &> /dev/null; then - log_warn "MySQL client n'est pas installé" - else - log_info "MySQL client: ✓" - fi -} - -# Fonction pour créer le fichier .env si nécessaire -setup_env() { - log_info "Configuration des variables d'environnement..." - - if [ ! -f .env ]; then - log_warn "Fichier .env non trouvé. Création à partir du template..." - - cat > .env << 'EOF' -# Base de données -DATABASE_URL=mysql://user:password@localhost:3306/formation_manager - -# JWT Secret (IMPORTANT: Générer une clé aléatoire sécurisée) -JWT_SECRET=changez_cette_cle_secrete_par_une_valeur_aleatoire_tres_longue - -# OAuth Manus -OAUTH_SERVER_URL=https://api.manus.im -VITE_OAUTH_PORTAL_URL=https://login.manus.im -VITE_APP_ID= - -# Propriétaire -OWNER_OPEN_ID= -OWNER_NAME= - -# Application -VITE_APP_TITLE=Gestion des Formations Manager Itinova -VITE_APP_LOGO=/logo.svg - -# APIs Manus (optionnel) -BUILT_IN_FORGE_API_URL=https://forge.manus.im -BUILT_IN_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_URL=https://forge.manus.im - -# Analytics (optionnel) -VITE_ANALYTICS_ENDPOINT= -VITE_ANALYTICS_WEBSITE_ID= - -# Environnement -NODE_ENV=production -EOF - - log_warn "Fichier .env créé. IMPORTANT: Modifiez les valeurs avant de continuer!" - log_warn "Éditez le fichier .env avec vos paramètres de production" - read -p "Appuyez sur Entrée après avoir configuré le fichier .env..." - else - log_info "Fichier .env trouvé ✓" - fi -} - -# Fonction pour installer les dépendances -install_dependencies() { - log_info "Installation des dépendances..." - pnpm install --frozen-lockfile - log_info "Dépendances installées ✓" -} - -# Fonction pour sauvegarder la base de données -backup_database() { - if [ -z "$DATABASE_URL" ]; then - log_warn "DATABASE_URL non définie, sauvegarde de la base de données ignorée" - return - fi - - log_info "Sauvegarde de la base de données..." - - BACKUP_DIR="./backups" - mkdir -p $BACKUP_DIR - - TIMESTAMP=$(date +%Y%m%d_%H%M%S) - BACKUP_FILE="$BACKUP_DIR/db_backup_$TIMESTAMP.sql" - - # Extraire les informations de connexion de DATABASE_URL - # Format: mysql://user:password@host:port/database - DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') - DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') - DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') - DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') - DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - - if command -v mysqldump &> /dev/null; then - mysqldump -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS $DB_NAME > $BACKUP_FILE 2>/dev/null || { - log_warn "Impossible de créer la sauvegarde de la base de données" - return - } - - gzip $BACKUP_FILE - log_info "Sauvegarde créée: $BACKUP_FILE.gz ✓" - - # Supprimer les sauvegardes de plus de 30 jours - find $BACKUP_DIR -name "db_backup_*.sql.gz" -mtime +30 -delete - else - log_warn "mysqldump non disponible, sauvegarde ignorée" - fi -} - -# Fonction pour appliquer les migrations -run_migrations() { - log_info "Application des migrations de base de données..." - pnpm db:push - log_info "Migrations appliquées ✓" -} - -# Fonction pour build l'application -build_app() { - log_info "Build de l'application..." - pnpm build - log_info "Build terminé ✓" -} - -# Fonction pour redémarrer le service -restart_service() { - if command -v systemctl &> /dev/null; then - SERVICE_NAME="formation-manager" - - if systemctl is-active --quiet $SERVICE_NAME; then - log_info "Redémarrage du service $SERVICE_NAME..." - sudo systemctl restart $SERVICE_NAME - - # Attendre que le service soit actif - sleep 3 - - if systemctl is-active --quiet $SERVICE_NAME; then - log_info "Service redémarré avec succès ✓" - else - log_error "Échec du redémarrage du service" - sudo systemctl status $SERVICE_NAME - exit 1 - fi - else - log_warn "Le service $SERVICE_NAME n'est pas actif" - log_info "Démarrage du service..." - sudo systemctl start $SERVICE_NAME - fi - else - log_warn "systemctl non disponible. Redémarrage manuel requis." - fi -} - -# Fonction pour vérifier le déploiement -verify_deployment() { - log_info "Vérification du déploiement..." - - # Vérifier que le serveur répond - if command -v curl &> /dev/null; then - sleep 2 - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000") - - if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then - log_info "Application accessible ✓" - else - log_warn "L'application ne répond pas correctement (HTTP $HTTP_CODE)" - fi - fi -} - -# Fonction principale -main() { - echo "╔════════════════════════════════════════════════════════════════╗" - echo "║ Déploiement - Gestion des Formations Manager Itinova ║" - echo "╚════════════════════════════════════════════════════════════════╝" - echo "" - - # Vérifier que nous sommes dans le bon répertoire - if [ ! -f "package.json" ]; then - log_error "Ce script doit être exécuté depuis la racine du projet" - exit 1 - fi - - # Étapes de déploiement - check_prerequisites - setup_env - - # Charger les variables d'environnement - if [ -f .env ]; then - export $(cat .env | grep -v '^#' | xargs) - fi - - # Demander confirmation avant de continuer - echo "" - log_warn "Le déploiement va:" - echo " 1. Sauvegarder la base de données" - echo " 2. Installer les dépendances" - echo " 3. Appliquer les migrations" - echo " 4. Builder l'application" - echo " 5. Redémarrer le service" - echo "" - read -p "Continuer? (y/N) " -n 1 -r - echo "" - - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - log_info "Déploiement annulé" - exit 0 - fi - - backup_database - install_dependencies - run_migrations - build_app - restart_service - verify_deployment - - echo "" - echo "╔════════════════════════════════════════════════════════════════╗" - echo "║ Déploiement terminé avec succès! ✓ ║" - echo "╚════════════════════════════════════════════════════════════════╝" - echo "" - log_info "L'application est maintenant accessible" - log_info "Vérifiez les logs avec: sudo journalctl -u formation-manager -f" -} - -# Exécuter le script -main "$@" diff --git a/deployment-package/scripts/diagnose.sh b/deployment-package/scripts/diagnose.sh deleted file mode 100755 index af61715..0000000 --- a/deployment-package/scripts/diagnose.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de diagnostic - Gestion des Formations Manager Itinova -# Vérifie la configuration et l'état de l'application -############################################################################### - -set -e - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Diagnostic - Formation Manager Itinova ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# Vérifier que nous sommes dans le bon répertoire -if [ ! -f "package.json" ]; then - echo -e "${RED}[ERREUR]${NC} Ce script doit être exécuté depuis la racine du projet" - exit 1 -fi - -# Charger les variables d'environnement -if [ -f .env ]; then - export $(cat .env | grep -v '^#' | xargs) - echo -e "${GREEN}[OK]${NC} Fichier .env trouvé" -else - echo -e "${RED}[ERREUR]${NC} Fichier .env non trouvé" - exit 1 -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 1. VÉRIFICATION DES VARIABLES D'ENVIRONNEMENT" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Vérifier DATABASE_URL -if [ -z "$DATABASE_URL" ]; then - echo -e "${RED}[ERREUR]${NC} DATABASE_URL n'est pas définie" -else - echo -e "${GREEN}[OK]${NC} DATABASE_URL est définie" - # Masquer le mot de passe - MASKED_URL=$(echo $DATABASE_URL | sed 's/:\/\/[^:]*:[^@]*@/:\/\/***:***@/') - echo " $MASKED_URL" -fi - -# Vérifier JWT_SECRET -if [ -z "$JWT_SECRET" ]; then - echo -e "${RED}[ERREUR]${NC} JWT_SECRET n'est pas définie" - echo -e "${YELLOW}[INFO]${NC} L'authentification locale ne fonctionnera pas sans JWT_SECRET" -else - echo -e "${GREEN}[OK]${NC} JWT_SECRET est définie" - SECRET_LENGTH=${#JWT_SECRET} - if [ $SECRET_LENGTH -lt 32 ]; then - echo -e "${YELLOW}[WARN]${NC} JWT_SECRET est trop courte ($SECRET_LENGTH caractères, recommandé: 32+)" - else - echo -e "${GREEN}[OK]${NC} JWT_SECRET a une longueur suffisante ($SECRET_LENGTH caractères)" - fi -fi - -# Vérifier NODE_ENV -if [ -z "$NODE_ENV" ]; then - echo -e "${YELLOW}[WARN]${NC} NODE_ENV n'est pas définie (défaut: development)" -else - echo -e "${GREEN}[OK]${NC} NODE_ENV = $NODE_ENV" -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 2. VÉRIFICATION DE LA BASE DE DONNÉES" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Extraire les informations de connexion -DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') -DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') -DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') -DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') -DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') - -# Tester la connexion MySQL -if command -v mysql &> /dev/null; then - if mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -e "USE $DB_NAME;" 2>/dev/null; then - echo -e "${GREEN}[OK]${NC} Connexion à la base de données réussie" - - # Vérifier les tables - TABLES=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -e "SHOW TABLES;" 2>/dev/null | tail -n +2) - - if [ -z "$TABLES" ]; then - echo -e "${YELLOW}[WARN]${NC} Aucune table trouvée dans la base de données" - echo -e "${YELLOW}[INFO]${NC} Exécutez 'pnpm db:push' pour créer les tables" - else - echo -e "${GREEN}[OK]${NC} Tables trouvées:" - echo "$TABLES" | while read table; do - echo " - $table" - done - - # Vérifier la table users - if echo "$TABLES" | grep -q "users"; then - USER_COUNT=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -e "SELECT COUNT(*) FROM users;" 2>/dev/null | tail -n 1) - echo "" - echo -e "${GREEN}[OK]${NC} Table 'users' existe" - echo " Nombre d'utilisateurs: $USER_COUNT" - - # Vérifier si adminServFormation existe - ADMIN_EXISTS=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -e "SELECT COUNT(*) FROM users WHERE username='adminServFormation';" 2>/dev/null | tail -n 1) - - if [ "$ADMIN_EXISTS" = "1" ]; then - echo -e "${GREEN}[OK]${NC} Utilisateur 'adminServFormation' existe" - - # Vérifier le mot de passe - PASSWORD_HASH=$(mysql -h $DB_HOST -P $DB_PORT -u $DB_USER -p$DB_PASS -D $DB_NAME -e "SELECT password FROM users WHERE username='adminServFormation';" 2>/dev/null | tail -n 1) - - if [ -z "$PASSWORD_HASH" ] || [ "$PASSWORD_HASH" = "NULL" ]; then - echo -e "${RED}[ERREUR]${NC} Le mot de passe de 'adminServFormation' est vide ou NULL" - else - echo -e "${GREEN}[OK]${NC} Le mot de passe est défini (hash: ${PASSWORD_HASH:0:20}...)" - fi - else - echo -e "${RED}[ERREUR]${NC} Utilisateur 'adminServFormation' n'existe pas" - echo -e "${YELLOW}[INFO]${NC} Utilisez le script 'reset-admin.sh' pour le créer" - fi - else - echo -e "${RED}[ERREUR]${NC} Table 'users' n'existe pas" - echo -e "${YELLOW}[INFO]${NC} Exécutez 'pnpm db:push' pour créer les tables" - fi - fi - else - echo -e "${RED}[ERREUR]${NC} Impossible de se connecter à la base de données" - echo -e "${YELLOW}[INFO]${NC} Vérifiez les paramètres de connexion dans .env" - fi -else - echo -e "${YELLOW}[WARN]${NC} MySQL client non installé, impossible de vérifier la base de données" -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 3. VÉRIFICATION DES DÉPENDANCES" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Vérifier node_modules -if [ -d "node_modules" ]; then - echo -e "${GREEN}[OK]${NC} Dossier node_modules existe" - - # Vérifier bcryptjs - if [ -d "node_modules/bcryptjs" ]; then - echo -e "${GREEN}[OK]${NC} bcryptjs est installé" - else - echo -e "${RED}[ERREUR]${NC} bcryptjs n'est pas installé" - echo -e "${YELLOW}[INFO]${NC} Exécutez 'pnpm install' pour installer les dépendances" - fi - - # Vérifier jsonwebtoken - if [ -d "node_modules/jsonwebtoken" ]; then - echo -e "${GREEN}[OK]${NC} jsonwebtoken est installé" - else - echo -e "${RED}[ERREUR]${NC} jsonwebtoken n'est pas installé" - echo -e "${YELLOW}[INFO]${NC} Exécutez 'pnpm install' pour installer les dépendances" - fi -else - echo -e "${RED}[ERREUR]${NC} Dossier node_modules n'existe pas" - echo -e "${YELLOW}[INFO]${NC} Exécutez 'pnpm install' pour installer les dépendances" -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 4. VÉRIFICATION DU SERVICE" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Vérifier si le service systemd existe -if command -v systemctl &> /dev/null; then - if systemctl list-unit-files | grep -q "formation-manager"; then - echo -e "${GREEN}[OK]${NC} Service systemd 'formation-manager' existe" - - if systemctl is-active --quiet formation-manager; then - echo -e "${GREEN}[OK]${NC} Service est actif" - else - echo -e "${YELLOW}[WARN]${NC} Service n'est pas actif" - fi - - if systemctl is-enabled --quiet formation-manager; then - echo -e "${GREEN}[OK]${NC} Service est activé au démarrage" - else - echo -e "${YELLOW}[WARN]${NC} Service n'est pas activé au démarrage" - fi - else - echo -e "${YELLOW}[INFO]${NC} Service systemd 'formation-manager' n'existe pas" - fi -fi - -# Vérifier si l'application répond -echo "" -if command -v curl &> /dev/null; then - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000") - - if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then - echo -e "${GREEN}[OK]${NC} Application répond (HTTP $HTTP_CODE)" - else - echo -e "${YELLOW}[WARN]${NC} Application ne répond pas correctement (HTTP $HTTP_CODE)" - fi -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " RÉSUMÉ DU DIAGNOSTIC" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -if [ -z "$JWT_SECRET" ]; then - echo -e "${RED}[ACTION REQUISE]${NC} Définir JWT_SECRET dans .env" - echo " Générer avec: openssl rand -base64 32" -fi - -if [ "$ADMIN_EXISTS" != "1" ]; then - echo -e "${RED}[ACTION REQUISE]${NC} Créer l'utilisateur adminServFormation" - echo " Exécuter: ./reset-admin.sh" -fi - -if [ -z "$TABLES" ]; then - echo -e "${RED}[ACTION REQUISE]${NC} Initialiser la base de données" - echo " Exécuter: pnpm db:push" -fi - -echo "" -echo "Diagnostic terminé." diff --git a/deployment-package/scripts/reset-admin.sh b/deployment-package/scripts/reset-admin.sh deleted file mode 100755 index 5172ea0..0000000 --- a/deployment-package/scripts/reset-admin.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de réinitialisation de l'utilisateur administrateur -# Wrapper pour le script Node.js reset-admin.js -############################################################################### - -set -e - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Vérifier que nous sommes dans le bon répertoire -if [ ! -f "package.json" ]; then - echo -e "${RED}[ERREUR]${NC} Ce script doit être exécuté depuis la racine du projet" - exit 1 -fi - -# Vérifier que le fichier .env existe -if [ ! -f ".env" ]; then - echo -e "${RED}[ERREUR]${NC} Fichier .env non trouvé" - echo -e "${YELLOW}[INFO]${NC} Créez un fichier .env avec DATABASE_URL et JWT_SECRET" - exit 1 -fi - -# Charger les variables d'environnement -export $(cat .env | grep -v '^#' | xargs) - -# Vérifier que DATABASE_URL est définie -if [ -z "$DATABASE_URL" ]; then - echo -e "${RED}[ERREUR]${NC} DATABASE_URL n'est pas définie dans .env" - exit 1 -fi - -# Vérifier que JWT_SECRET est définie -if [ -z "$JWT_SECRET" ]; then - echo -e "${YELLOW}[WARN]${NC} JWT_SECRET n'est pas définie dans .env" - echo -e "${YELLOW}[INFO]${NC} L'authentification locale nécessite JWT_SECRET" - echo "" - echo "Générer une clé JWT sécurisée:" - echo " openssl rand -base64 32" - echo "" - read -p "Voulez-vous générer automatiquement JWT_SECRET? (y/N) " -n 1 -r - echo "" - - if [[ $REPLY =~ ^[Yy]$ ]]; then - NEW_JWT_SECRET=$(openssl rand -base64 32) - echo "JWT_SECRET=$NEW_JWT_SECRET" >> .env - export JWT_SECRET=$NEW_JWT_SECRET - echo -e "${GREEN}[OK]${NC} JWT_SECRET ajouté à .env" - echo "" - else - echo -e "${RED}[ERREUR]${NC} JWT_SECRET est requis. Ajoutez-le manuellement dans .env" - exit 1 - fi -fi - -# Exécuter le script Node.js -node reset-admin.js diff --git a/deployment-package/scripts/setup-env.sh b/deployment-package/scripts/setup-env.sh deleted file mode 100755 index c0a1abc..0000000 --- a/deployment-package/scripts/setup-env.sh +++ /dev/null @@ -1,159 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script de configuration du fichier .env pour production -############################################################################### - -set -e - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Configuration du fichier .env pour production ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# Vérifier si .env existe déjà -if [ -f ".env" ]; then - echo -e "${YELLOW}[WARN]${NC} Le fichier .env existe déjà" - read -p "Voulez-vous le remplacer? (y/N) " -n 1 -r - echo "" - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Configuration annulée" - exit 0 - fi - # Sauvegarder l'ancien - cp .env .env.backup.$(date +%Y%m%d_%H%M%S) - echo -e "${GREEN}[OK]${NC} Ancien fichier sauvegardé" -fi - -echo "Ce script va créer un fichier .env avec les paramètres minimaux requis." -echo "" - -# Demander DATABASE_URL -echo "═══════════════════════════════════════════════════════════════" -echo " 1. Configuration de la base de données" -echo "═══════════════════════════════════════════════════════════════" -echo "" -read -p "Hôte MySQL (défaut: localhost): " DB_HOST -DB_HOST=${DB_HOST:-localhost} - -read -p "Port MySQL (défaut: 3306): " DB_PORT -DB_PORT=${DB_PORT:-3306} - -read -p "Nom de la base de données (défaut: formation_manager): " DB_NAME -DB_NAME=${DB_NAME:-formation_manager} - -read -p "Utilisateur MySQL: " DB_USER -read -sp "Mot de passe MySQL: " DB_PASS -echo "" - -DATABASE_URL="mysql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}" - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 2. Génération du JWT_SECRET" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -JWT_SECRET=$(openssl rand -base64 32) -echo -e "${GREEN}[OK]${NC} JWT_SECRET généré: ${JWT_SECRET:0:20}..." - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " 3. Configuration de l'application" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -read -p "Port de l'application (défaut: 3000): " PORT -PORT=${PORT:-3000} - -read -p "Titre de l'application (défaut: Gestion des Formations Manager Itinova): " APP_TITLE -APP_TITLE=${APP_TITLE:-Gestion des Formations Manager Itinova} - -# Créer le fichier .env -cat > .env << EOF -# ============================================================================ -# CONFIGURATION DE PRODUCTION - Formation Manager Itinova -# Généré le $(date) -# ============================================================================ - -# ---------------------------------------------------------------------------- -# Base de données -# ---------------------------------------------------------------------------- -DATABASE_URL=${DATABASE_URL} - -# ---------------------------------------------------------------------------- -# Sécurité - JWT -# ---------------------------------------------------------------------------- -JWT_SECRET=${JWT_SECRET} - -# ---------------------------------------------------------------------------- -# Application -# ---------------------------------------------------------------------------- -PORT=${PORT} -NODE_ENV=production - -# Titre de l'application -VITE_APP_TITLE=${APP_TITLE} - -# Logo de l'application -VITE_APP_LOGO=/logo-itinova.png - -# ---------------------------------------------------------------------------- -# OAuth Manus (configuration minimale pour éviter les erreurs) -# ---------------------------------------------------------------------------- -VITE_APP_ID=formation-manager-itinova -OAUTH_SERVER_URL=https://api.manus.im -VITE_OAUTH_PORTAL_URL=https://portal.manus.im -OWNER_OPEN_ID=local-owner -OWNER_NAME=Administrateur - -# ---------------------------------------------------------------------------- -# SMTP - Configuration email (optionnel) -# ---------------------------------------------------------------------------- -# Laissez vide si vous n'utilisez pas l'envoi d'emails -SMTP_HOST= -SMTP_PORT= -SMTP_USER= -SMTP_PASS= -SMTP_FROM= - -# ---------------------------------------------------------------------------- -# Analytics (optionnel) -# ---------------------------------------------------------------------------- -VITE_ANALYTICS_ENDPOINT= -VITE_ANALYTICS_WEBSITE_ID= - -# ---------------------------------------------------------------------------- -# API Forge Manus (optionnel) -# ---------------------------------------------------------------------------- -BUILT_IN_FORGE_API_URL= -BUILT_IN_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_KEY= -VITE_FRONTEND_FORGE_API_URL= -EOF - -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Fichier .env créé avec succès! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "📄 Fichier créé: $(pwd)/.env" -echo "" -echo "⚠️ IMPORTANT:" -echo " 1. Le fichier .env contient des informations sensibles" -echo " 2. Ne le partagez jamais publiquement" -echo " 3. Assurez-vous qu'il n'est pas accessible via le web" -echo "" -echo "🔄 Prochaines étapes:" -echo " 1. Vérifiez la configuration: cat .env" -echo " 2. Appliquez les migrations: pnpm db:push" -echo " 3. Compilez l'application: pnpm build" -echo " 4. Redémarrez le service: sudo systemctl restart formation-manager" -echo "" diff --git a/deployment-package/scripts/setup-https.sh b/deployment-package/scripts/setup-https.sh deleted file mode 100755 index 5c9cf8f..0000000 --- a/deployment-package/scripts/setup-https.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/bin/bash - -############################################################################### -# Script d'installation automatique de HTTPS avec Let's Encrypt -# Pour l'application Formation Manager Itinova -############################################################################### - -set -e - -# Couleurs -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Installation HTTPS avec Let's Encrypt et nginx ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# Vérifier que le script est exécuté en tant que root -if [ "$EUID" -ne 0 ]; then - echo -e "${RED}[ERREUR]${NC} Ce script doit être exécuté en tant que root (sudo)" - exit 1 -fi - -# Demander le nom de domaine -echo "═══════════════════════════════════════════════════════════════" -echo " Configuration du domaine" -echo "═══════════════════════════════════════════════════════════════" -echo "" -read -p "Nom de domaine (ex: formations.itinova.org): " DOMAIN - -if [ -z "$DOMAIN" ]; then - echo -e "${RED}[ERREUR]${NC} Le nom de domaine est obligatoire" - exit 1 -fi - -# Demander l'email pour Let's Encrypt -read -p "Email pour les notifications Let's Encrypt: " EMAIL - -if [ -z "$EMAIL" ]; then - echo -e "${RED}[ERREUR]${NC} L'email est obligatoire" - exit 1 -fi - -# Demander le chemin de l'application -read -p "Chemin de l'application (défaut: /var/www/formation-manager-itinova): " APP_PATH -APP_PATH=${APP_PATH:-/var/www/formation-manager-itinova} - -# Demander le port de l'application -read -p "Port de l'application (défaut: 3000): " APP_PORT -APP_PORT=${APP_PORT:-3000} - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Résumé de la configuration" -echo "═══════════════════════════════════════════════════════════════" -echo "" -echo "Domaine: $DOMAIN" -echo "Email: $EMAIL" -echo "Chemin application: $APP_PATH" -echo "Port application: $APP_PORT" -echo "" -read -p "Continuer avec cette configuration? (y/N) " -n 1 -r -echo "" -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Installation annulée" - exit 0 -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 1/6 : Mise à jour du système" -echo "═══════════════════════════════════════════════════════════════" -echo "" -apt update -apt upgrade -y - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 2/6 : Installation de nginx et certbot" -echo "═══════════════════════════════════════════════════════════════" -echo "" -apt install -y nginx certbot python3-certbot-nginx - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 3/6 : Configuration de nginx (HTTP)" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Créer la configuration nginx -cat > /etc/nginx/sites-available/formation-manager << EOF -server { - listen 80; - listen [::]:80; - server_name $DOMAIN; - - # Logs - access_log /var/log/nginx/formation-manager-access.log; - error_log /var/log/nginx/formation-manager-error.log; - - # Proxy vers l'application Node.js - location / { - proxy_pass http://localhost:$APP_PORT; - proxy_http_version 1.1; - proxy_set_header Upgrade \$http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$scheme; - proxy_cache_bypass \$http_upgrade; - - # Timeouts - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } -} -EOF - -# Activer la configuration -ln -sf /etc/nginx/sites-available/formation-manager /etc/nginx/sites-enabled/ - -# Supprimer la configuration par défaut -rm -f /etc/nginx/sites-enabled/default - -# Tester la configuration -nginx -t - -# Redémarrer nginx -systemctl restart nginx -systemctl enable nginx - -echo -e "${GREEN}[OK]${NC} Configuration nginx créée et activée" - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 4/6 : Vérification DNS" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Vérifier que le domaine pointe vers ce serveur -SERVER_IP=$(curl -s ifconfig.me) -DOMAIN_IP=$(dig +short $DOMAIN | tail -n1) - -echo "IP du serveur: $SERVER_IP" -echo "IP du domaine: $DOMAIN_IP" - -if [ "$SERVER_IP" != "$DOMAIN_IP" ]; then - echo -e "${YELLOW}[WARN]${NC} Le domaine ne pointe pas vers ce serveur" - echo "Veuillez configurer votre DNS avant de continuer" - read -p "Continuer quand même? (y/N) " -n 1 -r - echo "" - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Installation annulée" - exit 0 - fi -fi - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 5/6 : Obtention du certificat SSL" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Obtenir le certificat avec certbot -certbot --nginx -d $DOMAIN --non-interactive --agree-tos --email $EMAIL --redirect - -echo -e "${GREEN}[OK]${NC} Certificat SSL obtenu et installé" - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Étape 6/6 : Configuration avancée de nginx" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Ajouter des optimisations à la configuration HTTPS -# Trouver le bloc server HTTPS (port 443) et ajouter les optimisations -cat > /tmp/nginx-ssl-additions << 'EOF' - - # Compression gzip - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; - - # Sécurité headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - - # Cache des fichiers statiques - location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { - proxy_pass http://localhost:$APP_PORT; - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Limiter la taille des uploads - client_max_body_size 10M; -EOF - -echo -e "${GREEN}[OK]${NC} Optimisations ajoutées" - -# Tester et recharger nginx -nginx -t -systemctl reload nginx - -echo "" -echo "═══════════════════════════════════════════════════════════════" -echo " Mise à jour de la configuration de l'application" -echo "═══════════════════════════════════════════════════════════════" -echo "" - -# Modifier la configuration des cookies pour HTTPS -if [ -f "$APP_PATH/server/_core/cookies.ts" ]; then - sed -i 's/sameSite: "lax"/sameSite: "none"/g' "$APP_PATH/server/_core/cookies.ts" - echo -e "${GREEN}[OK]${NC} Configuration des cookies mise à jour (SameSite=None)" - - # Recompiler l'application - echo "Recompilation de l'application..." - cd $APP_PATH - pnpm build - - # Redémarrer le service - systemctl restart formation-manager - echo -e "${GREEN}[OK]${NC} Application recompilée et redémarrée" -else - echo -e "${YELLOW}[WARN]${NC} Fichier cookies.ts non trouvé, modification manuelle nécessaire" -fi - -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Installation HTTPS terminée! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo -e "${GREEN}✅ Votre site est maintenant accessible en HTTPS${NC}" -echo "" -echo "🌐 URL: https://$DOMAIN" -echo "" -echo "📋 Prochaines étapes:" -echo " 1. Testez votre site: https://$DOMAIN" -echo " 2. Vérifiez la sécurité SSL: https://www.ssllabs.com/ssltest/" -echo " 3. Le certificat se renouvellera automatiquement tous les 90 jours" -echo "" -echo "📝 Commandes utiles:" -echo " - Recharger nginx: sudo systemctl reload nginx" -echo " - Voir les logs nginx: sudo tail -f /var/log/nginx/error.log" -echo " - Renouveler le certificat: sudo certbot renew" -echo " - Tester le renouvellement: sudo certbot renew --dry-run" -echo "" diff --git a/deployment-package/source_server/__tests__/localAuth.test.ts b/deployment-package/source_server/__tests__/localAuth.test.ts new file mode 100644 index 0000000..4385110 --- /dev/null +++ b/deployment-package/source_server/__tests__/localAuth.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb } from "../db"; +import { users } from "../../drizzle/schema"; +import { eq } from "drizzle-orm"; + +describe("Local Authentication", () => { + beforeAll(async () => { + // Vérifier que la base de données est accessible + const db = await getDb(); + expect(db).toBeDefined(); + }); + + it("should have an admin user with username adminServFormation", async () => { + const db = await getDb(); + if (!db) { + throw new Error("Database not available"); + } + + const result = await db + .select() + .from(users) + .where(eq(users.username, "adminServFormation")) + .limit(1); + + expect(result.length).toBe(1); + expect(result[0].username).toBe("adminServFormation"); + expect(result[0].role).toBe("admin"); + expect(result[0].password).toBeDefined(); + expect(result[0].password).not.toBeNull(); + }); + + it("should authenticate with correct credentials", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + password: "Itinova69!", + }), + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.user).toBeDefined(); + expect(data.user.username).toBe("adminServFormation"); + expect(data.user.role).toBe("admin"); + expect(data.user.password).toBeUndefined(); // Le mot de passe ne doit pas être retourné + }); + + it("should reject authentication with incorrect password", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + password: "wrongpassword", + }), + }); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("incorrect"); + }); + + it("should reject authentication with non-existent username", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "nonexistentuser", + password: "anypassword", + }), + }); + + expect(response.status).toBe(401); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("incorrect"); + }); + + it("should reject authentication with missing credentials", async () => { + const response = await fetch("http://localhost:3000/api/auth/local/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "adminServFormation", + // password manquant + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("requis"); + }); +}); diff --git a/deployment-package/source_server/_core/appUrl.ts b/deployment-package/source_server/_core/appUrl.ts new file mode 100644 index 0000000..5bc793a --- /dev/null +++ b/deployment-package/source_server/_core/appUrl.ts @@ -0,0 +1,32 @@ +/** + * Récupère l'URL de base de l'application + * En production, utilise l'URL publique configurée + * En développement, utilise localhost + */ +export function getAppBaseUrl(): string { + // Si une URL publique est configurée, l'utiliser + if (process.env.APP_PUBLIC_URL) { + return process.env.APP_PUBLIC_URL; + } + + // En production, essayer de détecter l'URL depuis les variables d'environnement Manus + if (process.env.NODE_ENV === "production") { + // Pour les applications Manus déployées, l'URL est généralement disponible + // via les variables d'environnement du système + const port = process.env.PORT || 3000; + + // Si on est sur un serveur VPS avec un domaine configuré + if (process.env.DOMAIN_NAME) { + return `https://${process.env.DOMAIN_NAME}`; + } + + // Sinon, utiliser l'URL du serveur si disponible + if (process.env.SERVER_URL) { + return process.env.SERVER_URL; + } + } + + // Par défaut, utiliser localhost (développement) + const port = process.env.PORT || 3000; + return `http://localhost:${port}`; +} diff --git a/deployment-package/source_server/_core/context.ts b/deployment-package/source_server/_core/context.ts new file mode 100644 index 0000000..e4ae108 --- /dev/null +++ b/deployment-package/source_server/_core/context.ts @@ -0,0 +1,28 @@ +import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; +import type { User } from "../../drizzle/schema"; +import { sdk } from "./sdk"; + +export type TrpcContext = { + req: CreateExpressContextOptions["req"]; + res: CreateExpressContextOptions["res"]; + user: User | null; +}; + +export async function createContext( + opts: CreateExpressContextOptions +): Promise { + let user: User | null = null; + + try { + user = await sdk.authenticateRequest(opts.req); + } catch (error) { + // Authentication is optional for public procedures. + user = null; + } + + return { + req: opts.req, + res: opts.res, + user, + }; +} diff --git a/deployment-package/source_server/_core/cookies.ts b/deployment-package/source_server/_core/cookies.ts new file mode 100644 index 0000000..664b6de --- /dev/null +++ b/deployment-package/source_server/_core/cookies.ts @@ -0,0 +1,48 @@ +import type { CookieOptions, Request } from "express"; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function isIpAddress(host: string) { + // Basic IPv4 check and IPv6 presence detection. + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; + return host.includes(":"); +} + +function isSecureRequest(req: Request) { + if (req.protocol === "https") return true; + + const forwardedProto = req.headers["x-forwarded-proto"]; + if (!forwardedProto) return false; + + const protoList = Array.isArray(forwardedProto) + ? forwardedProto + : forwardedProto.split(","); + + return protoList.some(proto => proto.trim().toLowerCase() === "https"); +} + +export function getSessionCookieOptions( + req: Request +): Pick { + // const hostname = req.hostname; + // const shouldSetDomain = + // hostname && + // !LOCAL_HOSTS.has(hostname) && + // !isIpAddress(hostname) && + // hostname !== "127.0.0.1" && + // hostname !== "::1"; + + // const domain = + // shouldSetDomain && !hostname.startsWith(".") + // ? `.${hostname}` + // : shouldSetDomain + // ? hostname + // : undefined; + + return { + httpOnly: true, + path: "/", + sameSite: "lax", + secure: isSecureRequest(req), + }; +} diff --git a/deployment-package/source_server/_core/dataApi.ts b/deployment-package/source_server/_core/dataApi.ts new file mode 100644 index 0000000..7ef81b5 --- /dev/null +++ b/deployment-package/source_server/_core/dataApi.ts @@ -0,0 +1,64 @@ +/** + * Quick example (matches curl usage): + * await callDataApi("Youtube/search", { + * query: { gl: "US", hl: "en", q: "manus" }, + * }) + */ +import { ENV } from "./env"; + +export type DataApiCallOptions = { + query?: Record; + body?: Record; + pathParams?: Record; + formData?: Record; +}; + +export async function callDataApi( + apiId: string, + options: DataApiCallOptions = {} +): Promise { + if (!ENV.forgeApiUrl) { + throw new Error("BUILT_IN_FORGE_API_URL is not configured"); + } + if (!ENV.forgeApiKey) { + throw new Error("BUILT_IN_FORGE_API_KEY is not configured"); + } + + // Build the full URL by appending the service path to the base URL + const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`; + const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString(); + + const response = await fetch(fullUrl, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "connect-protocol-version": "1", + authorization: `Bearer ${ENV.forgeApiKey}`, + }, + body: JSON.stringify({ + apiId, + query: options.query, + body: options.body, + path_params: options.pathParams, + multipart_form_data: options.formData, + }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}` + ); + } + + const payload = await response.json().catch(() => ({})); + if (payload && typeof payload === "object" && "jsonData" in payload) { + try { + return JSON.parse((payload as Record).jsonData ?? "{}"); + } catch { + return (payload as Record).jsonData; + } + } + return payload; +} diff --git a/deployment-package/source_server/_core/emailSender.ts b/deployment-package/source_server/_core/emailSender.ts new file mode 100644 index 0000000..9d6fd7d --- /dev/null +++ b/deployment-package/source_server/_core/emailSender.ts @@ -0,0 +1,206 @@ +/** + * Service d'envoi d'emails réels via Resend API + * + * Configuration requise: + * - RESEND_API_KEY: Clé API Resend (obtenir sur https://resend.com) + * - RESEND_FROM_EMAIL: Adresse email d'envoi (doit être vérifiée dans Resend) + * + * Si ces variables ne sont pas configurées, les emails seront simulés + * et envoyés comme notifications au propriétaire du projet. + */ + +import { ENV } from "./env"; +import { notifyOwner } from "./notification"; +import { getActiveEmailConfig } from "../db"; +import { sendViaSMTP } from "./smtpSender"; + +interface EmailParams { + to: string; + subject: string; + html: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; +} + +interface ResendEmailRequest { + from: string; + to: string[]; + subject: string; + html: string; + attachments?: Array<{ + filename: string; + content: string; + }>; +} + +/** + * Envoie un email via Resend API + */ +async function sendViaResend(params: EmailParams, config?: { apiKey: string; fromEmail: string; fromName: string }): Promise { + // Utiliser la config fournie ou les variables d'environnement + const resendApiKey = config?.apiKey || process.env.RESEND_API_KEY; + const fromEmail = config?.fromEmail || process.env.RESEND_FROM_EMAIL || 'noreply@manusvm.computer'; + const fromName = config?.fromName || 'Formation Manager Itinova'; + + if (!resendApiKey) { + console.warn('[Email] RESEND_API_KEY non configurée, email simulé'); + return false; + } + + try { + const payload: ResendEmailRequest = { + from: `${fromName} <${fromEmail}>`, + to: [params.to], + subject: params.subject, + html: params.html, + }; + + // Ajouter les pièces jointes si présentes + if (params.attachments && params.attachments.length > 0) { + payload.attachments = params.attachments.map(att => ({ + filename: att.filename, + content: att.content, // Resend accepte base64 ou string + })); + } + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${resendApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.text(); + console.error('[Email] Erreur Resend:', response.status, error); + return false; + } + + const result = await response.json(); + console.log('[Email] Email envoyé via Resend:', result.id); + return true; + } catch (error) { + console.error('[Email] Exception lors de l\'envoi via Resend:', error); + return false; + } +} + +/** + * Simule l'envoi d'un email en créant une notification pour le propriétaire + */ +async function simulateEmail(params: EmailParams): Promise { + console.log('=== EMAIL SIMULÉ ==='); + console.log('To:', params.to); + console.log('Subject:', params.subject); + + const emailContent = ` +Destinataire: ${params.to} +Sujet: ${params.subject} + +${params.html.replace(/<[^>]*>/g, '').substring(0, 500)}... + +${params.attachments ? `Pièces jointes: ${params.attachments.map(a => a.filename).join(', ')}` : ''} + +⚠️ Cet email est simulé. Pour envoyer de vrais emails: +1. Créez un compte sur https://resend.com +2. Ajoutez RESEND_API_KEY dans les secrets du projet +3. Ajoutez RESEND_FROM_EMAIL (ex: noreply@votredomaine.com)`; + + try { + await notifyOwner({ + title: `📧 Email simulé: ${params.subject}`, + content: emailContent, + }); + console.log('✓ Email simulé (notification envoyée au propriétaire)'); + } catch (error) { + console.error('Erreur lors de la simulation d\'email:', error); + } + + console.log('==================='); + + return true; +} + +/** + * Envoie un email (réel ou simulé selon la configuration) + */ +export async function sendEmail(params: EmailParams): Promise { + // Récupérer la configuration depuis la base de données + const config = await getActiveEmailConfig(); + + console.log('[Email] Configuration récupérée:', config ? { + provider: config.provider, + mode: config.mode, + fromEmail: config.fromEmail, + hasSmtpHost: !!config.smtpHost, + hasSmtpUser: !!config.smtpUser, + hasSmtpPassword: !!config.smtpPassword, + } : 'Aucune configuration'); + + // Si mode simulation ou pas de config, simuler + if (!config || config.mode === 'simulation') { + console.log('[Email] Mode simulation activé ou pas de config'); + return await simulateEmail(params); + } + + // Si mode production + if (config.mode === 'production') { + console.log('[Email] Mode production activé'); + + // Essayer SMTP en priorité si configuré + if (config.provider === 'smtp' && config.smtpHost && config.smtpPort && config.smtpUser && config.smtpPassword) { + console.log('[Email] Tentative d\'envoi via SMTP...'); + + const sent = await sendViaSMTP(params, { + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure || 'tls', + user: config.smtpUser, + password: config.smtpPassword, + fromEmail: config.fromEmail, + fromName: config.fromName, + }); + + // Si l'envoi échoue, simuler en fallback + if (!sent) { + console.log('[Email] Échec de l\'envoi SMTP, basculement en simulation'); + return await simulateEmail(params); + } + + console.log('[Email] Email envoyé avec succès via SMTP'); + return true; + } + + // Sinon essayer Resend + if (config.provider === 'resend' && config.apiKey) { + console.log('[Email] Tentative d\'envoi via Resend...'); + const sent = await sendViaResend(params, { + apiKey: config.apiKey, + fromEmail: config.fromEmail, + fromName: config.fromName, + }); + + // Si l'envoi échoue, simuler en fallback + if (!sent) { + return await simulateEmail(params); + } + + return true; + } + } + + // Fallback: simuler + return await simulateEmail(params); +} + +/** + * Vérifie si le service d'envoi d'emails réel est configuré + */ +export function isEmailServiceConfigured(): boolean { + return !!process.env.RESEND_API_KEY; +} diff --git a/deployment-package/source_server/_core/env.ts b/deployment-package/source_server/_core/env.ts new file mode 100644 index 0000000..305082c --- /dev/null +++ b/deployment-package/source_server/_core/env.ts @@ -0,0 +1,11 @@ +export const ENV = { + appId: process.env.VITE_APP_ID ?? "", + cookieSecret: process.env.JWT_SECRET ?? "", + jwtSecret: process.env.JWT_SECRET ?? "", + databaseUrl: process.env.DATABASE_URL ?? "", + oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "", + ownerOpenId: process.env.OWNER_OPEN_ID ?? "", + isProduction: process.env.NODE_ENV === "production", + forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", + forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", +}; diff --git a/deployment-package/source_server/_core/fileUpload.ts b/deployment-package/source_server/_core/fileUpload.ts new file mode 100644 index 0000000..c050be1 --- /dev/null +++ b/deployment-package/source_server/_core/fileUpload.ts @@ -0,0 +1,59 @@ +import { Request, Response } from "express"; +import multer from "multer"; +import { storagePut } from "../storage"; +import crypto from "crypto"; +import path from "path"; + +// Configuration de multer pour l'upload en mémoire +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10 Mo max + }, +}); + +// Middleware pour l'upload d'un seul fichier +export const uploadSingle = upload.single("file"); + +// Handler pour l'upload de fichier vers S3 +export async function handleFileUpload(req: Request, res: Response) { + try { + if (!req.file) { + return res.status(400).json({ error: "Aucun fichier fourni" }); + } + + const file = req.file; + + // Générer un nom de fichier unique + const randomSuffix = crypto.randomBytes(8).toString("hex"); + const ext = path.extname(file.originalname); + const baseName = path.basename(file.originalname, ext); + const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_"); + const fileName = `${safeBaseName}-${randomSuffix}${ext}`; + + // Chemin S3 pour les pièces jointes de rappels + const s3Key = `rappels-attachments/${fileName}`; + + // Upload vers S3 + const { url, key } = await storagePut( + s3Key, + file.buffer, + file.mimetype + ); + + console.log(`[FileUpload] Fichier uploadé: ${fileName} (${file.size} octets)`); + + return res.json({ + url, + key, + filename: file.originalname, + mimetype: file.mimetype, + size: file.size, + }); + } catch (error) { + console.error("[FileUpload] Erreur:", error); + return res.status(500).json({ + error: "Erreur lors de l'upload du fichier" + }); + } +} diff --git a/deployment-package/source_server/_core/imageGeneration.ts b/deployment-package/source_server/_core/imageGeneration.ts new file mode 100644 index 0000000..0ba0c98 --- /dev/null +++ b/deployment-package/source_server/_core/imageGeneration.ts @@ -0,0 +1,92 @@ +/** + * Image generation helper using internal ImageService + * + * Example usage: + * const { url: imageUrl } = await generateImage({ + * prompt: "A serene landscape with mountains" + * }); + * + * For editing: + * const { url: imageUrl } = await generateImage({ + * prompt: "Add a rainbow to this landscape", + * originalImages: [{ + * url: "https://example.com/original.jpg", + * mimeType: "image/jpeg" + * }] + * }); + */ +import { storagePut } from "server/storage"; +import { ENV } from "./env"; + +export type GenerateImageOptions = { + prompt: string; + originalImages?: Array<{ + url?: string; + b64Json?: string; + mimeType?: string; + }>; +}; + +export type GenerateImageResponse = { + url?: string; +}; + +export async function generateImage( + options: GenerateImageOptions +): Promise { + if (!ENV.forgeApiUrl) { + throw new Error("BUILT_IN_FORGE_API_URL is not configured"); + } + if (!ENV.forgeApiKey) { + throw new Error("BUILT_IN_FORGE_API_KEY is not configured"); + } + + // Build the full URL by appending the service path to the base URL + const baseUrl = ENV.forgeApiUrl.endsWith("/") + ? ENV.forgeApiUrl + : `${ENV.forgeApiUrl}/`; + const fullUrl = new URL( + "images.v1.ImageService/GenerateImage", + baseUrl + ).toString(); + + const response = await fetch(fullUrl, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "connect-protocol-version": "1", + authorization: `Bearer ${ENV.forgeApiKey}`, + }, + body: JSON.stringify({ + prompt: options.prompt, + original_images: options.originalImages || [], + }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}` + ); + } + + const result = (await response.json()) as { + image: { + b64Json: string; + mimeType: string; + }; + }; + const base64Data = result.image.b64Json; + const buffer = Buffer.from(base64Data, "base64"); + + // Save to S3 + const { url } = await storagePut( + `generated/${Date.now()}.png`, + buffer, + result.image.mimeType + ); + return { + url, + }; +} diff --git a/deployment-package/source_server/_core/index.ts b/deployment-package/source_server/_core/index.ts new file mode 100644 index 0000000..47aaafc --- /dev/null +++ b/deployment-package/source_server/_core/index.ts @@ -0,0 +1,82 @@ +import "dotenv/config"; +import express from "express"; +import { createServer } from "http"; +import net from "net"; +import { createExpressMiddleware } from "@trpc/server/adapters/express"; +import { registerOAuthRoutes } from "./oauth"; +import { appRouter } from "../routers"; +import { createContext } from "./context"; +import localAuthRouter from "./localAuth"; +import uploadImageRouter from "./uploadImage"; +import uploadFileRouter from "./uploadFile"; +import { serveStatic, setupVite } from "./vite"; +import { initRappelScheduler } from "../rappelScheduler"; +import { initRappelRetryScheduler } from "../rappelRetry"; + +function isPortAvailable(port: number): Promise { + return new Promise(resolve => { + const server = net.createServer(); + server.listen(port, () => { + server.close(() => resolve(true)); + }); + server.on("error", () => resolve(false)); + }); +} + +async function findAvailablePort(startPort: number = 3000): Promise { + for (let port = startPort; port < startPort + 20; port++) { + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`No available port found starting from ${startPort}`); +} + +async function startServer() { + const app = express(); + const server = createServer(app); + // Configure body parser with larger size limit for file uploads + app.use(express.json({ limit: "50mb" })); + app.use(express.urlencoded({ limit: "50mb", extended: true })); + // OAuth callback under /api/oauth/callback + registerOAuthRoutes(app); + // Local authentication under /api/auth/local + app.use("/api/auth/local", localAuthRouter); + // Image upload under /api/upload-image + app.use("/api/upload-image", uploadImageRouter); + // File upload under /api/upload-file + app.use("/api/upload-file", uploadFileRouter); + // tRPC API + app.use( + "/api/trpc", + createExpressMiddleware({ + router: appRouter, + createContext, + }) + ); + // development mode uses Vite, production mode uses static files + if (process.env.NODE_ENV === "development") { + await setupVite(app, server); + } else { + serveStatic(app); + } + + const preferredPort = parseInt(process.env.PORT || "3000"); + const port = await findAvailablePort(preferredPort); + + if (port !== preferredPort) { + console.log(`Port ${preferredPort} is busy, using port ${port} instead`); + } + + server.listen(port, () => { + console.log(`Server running on http://localhost:${port}/`); + + // Initialiser le scheduler de rappels automatiques + initRappelScheduler(); + + // Initialiser le scheduler de réessai automatique + initRappelRetryScheduler(); + }); +} + +startServer().catch(console.error); diff --git a/deployment-package/source_server/_core/llm.ts b/deployment-package/source_server/_core/llm.ts new file mode 100644 index 0000000..8ea4c4a --- /dev/null +++ b/deployment-package/source_server/_core/llm.ts @@ -0,0 +1,332 @@ +import { ENV } from "./env"; + +export type Role = "system" | "user" | "assistant" | "tool" | "function"; + +export type TextContent = { + type: "text"; + text: string; +}; + +export type ImageContent = { + type: "image_url"; + image_url: { + url: string; + detail?: "auto" | "low" | "high"; + }; +}; + +export type FileContent = { + type: "file_url"; + file_url: { + url: string; + mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ; + }; +}; + +export type MessageContent = string | TextContent | ImageContent | FileContent; + +export type Message = { + role: Role; + content: MessageContent | MessageContent[]; + name?: string; + tool_call_id?: string; +}; + +export type Tool = { + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; + }; +}; + +export type ToolChoicePrimitive = "none" | "auto" | "required"; +export type ToolChoiceByName = { name: string }; +export type ToolChoiceExplicit = { + type: "function"; + function: { + name: string; + }; +}; + +export type ToolChoice = + | ToolChoicePrimitive + | ToolChoiceByName + | ToolChoiceExplicit; + +export type InvokeParams = { + messages: Message[]; + tools?: Tool[]; + toolChoice?: ToolChoice; + tool_choice?: ToolChoice; + maxTokens?: number; + max_tokens?: number; + outputSchema?: OutputSchema; + output_schema?: OutputSchema; + responseFormat?: ResponseFormat; + response_format?: ResponseFormat; +}; + +export type ToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type InvokeResult = { + id: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: Role; + content: string | Array; + tool_calls?: ToolCall[]; + }; + finish_reason: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +}; + +export type JsonSchema = { + name: string; + schema: Record; + strict?: boolean; +}; + +export type OutputSchema = JsonSchema; + +export type ResponseFormat = + | { type: "text" } + | { type: "json_object" } + | { type: "json_schema"; json_schema: JsonSchema }; + +const ensureArray = ( + value: MessageContent | MessageContent[] +): MessageContent[] => (Array.isArray(value) ? value : [value]); + +const normalizeContentPart = ( + part: MessageContent +): TextContent | ImageContent | FileContent => { + if (typeof part === "string") { + return { type: "text", text: part }; + } + + if (part.type === "text") { + return part; + } + + if (part.type === "image_url") { + return part; + } + + if (part.type === "file_url") { + return part; + } + + throw new Error("Unsupported message content part"); +}; + +const normalizeMessage = (message: Message) => { + const { role, name, tool_call_id } = message; + + if (role === "tool" || role === "function") { + const content = ensureArray(message.content) + .map(part => (typeof part === "string" ? part : JSON.stringify(part))) + .join("\n"); + + return { + role, + name, + tool_call_id, + content, + }; + } + + const contentParts = ensureArray(message.content).map(normalizeContentPart); + + // If there's only text content, collapse to a single string for compatibility + if (contentParts.length === 1 && contentParts[0].type === "text") { + return { + role, + name, + content: contentParts[0].text, + }; + } + + return { + role, + name, + content: contentParts, + }; +}; + +const normalizeToolChoice = ( + toolChoice: ToolChoice | undefined, + tools: Tool[] | undefined +): "none" | "auto" | ToolChoiceExplicit | undefined => { + if (!toolChoice) return undefined; + + if (toolChoice === "none" || toolChoice === "auto") { + return toolChoice; + } + + if (toolChoice === "required") { + if (!tools || tools.length === 0) { + throw new Error( + "tool_choice 'required' was provided but no tools were configured" + ); + } + + if (tools.length > 1) { + throw new Error( + "tool_choice 'required' needs a single tool or specify the tool name explicitly" + ); + } + + return { + type: "function", + function: { name: tools[0].function.name }, + }; + } + + if ("name" in toolChoice) { + return { + type: "function", + function: { name: toolChoice.name }, + }; + } + + return toolChoice; +}; + +const resolveApiUrl = () => + ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 + ? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions` + : "https://forge.manus.im/v1/chat/completions"; + +const assertApiKey = () => { + if (!ENV.forgeApiKey) { + throw new Error("OPENAI_API_KEY is not configured"); + } +}; + +const normalizeResponseFormat = ({ + responseFormat, + response_format, + outputSchema, + output_schema, +}: { + responseFormat?: ResponseFormat; + response_format?: ResponseFormat; + outputSchema?: OutputSchema; + output_schema?: OutputSchema; +}): + | { type: "json_schema"; json_schema: JsonSchema } + | { type: "text" } + | { type: "json_object" } + | undefined => { + const explicitFormat = responseFormat || response_format; + if (explicitFormat) { + if ( + explicitFormat.type === "json_schema" && + !explicitFormat.json_schema?.schema + ) { + throw new Error( + "responseFormat json_schema requires a defined schema object" + ); + } + return explicitFormat; + } + + const schema = outputSchema || output_schema; + if (!schema) return undefined; + + if (!schema.name || !schema.schema) { + throw new Error("outputSchema requires both name and schema"); + } + + return { + type: "json_schema", + json_schema: { + name: schema.name, + schema: schema.schema, + ...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}), + }, + }; +}; + +export async function invokeLLM(params: InvokeParams): Promise { + assertApiKey(); + + const { + messages, + tools, + toolChoice, + tool_choice, + outputSchema, + output_schema, + responseFormat, + response_format, + } = params; + + const payload: Record = { + model: "gemini-2.5-flash", + messages: messages.map(normalizeMessage), + }; + + if (tools && tools.length > 0) { + payload.tools = tools; + } + + const normalizedToolChoice = normalizeToolChoice( + toolChoice || tool_choice, + tools + ); + if (normalizedToolChoice) { + payload.tool_choice = normalizedToolChoice; + } + + payload.max_tokens = 32768 + payload.thinking = { + "budget_tokens": 128 + } + + const normalizedResponseFormat = normalizeResponseFormat({ + responseFormat, + response_format, + outputSchema, + output_schema, + }); + + if (normalizedResponseFormat) { + payload.response_format = normalizedResponseFormat; + } + + const response = await fetch(resolveApiUrl(), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${ENV.forgeApiKey}`, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}` + ); + } + + return (await response.json()) as InvokeResult; +} diff --git a/deployment-package/source_server/_core/localAuth.ts b/deployment-package/source_server/_core/localAuth.ts new file mode 100644 index 0000000..b020893 --- /dev/null +++ b/deployment-package/source_server/_core/localAuth.ts @@ -0,0 +1,102 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import { getDb } from "../db"; +import { users } from "../../drizzle/schema"; +import { eq } from "drizzle-orm"; +import { COOKIE_NAME } from "@shared/const"; +import { getSessionCookieOptions } from "./cookies"; +import { sdk } from "./sdk"; + +const router = Router(); + +/** + * Route d'authentification locale avec username/password + * POST /api/auth/local/login + * Body: { username: string, password: string } + */ +router.post("/login", async (req, res) => { + try { + const { username, password } = req.body; + + // Validation des champs + if (!username || !password) { + return res.status(400).json({ + success: false, + message: "Identifiant et mot de passe requis", + }); + } + + // Récupérer l'utilisateur par username + const db = await getDb(); + if (!db) { + return res.status(500).json({ + success: false, + message: "Erreur de connexion à la base de données", + }); + } + + const result = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (result.length === 0) { + return res.status(401).json({ + success: false, + message: "Identifiant ou mot de passe incorrect", + }); + } + + const user = result[0]; + + // Vérifier que l'utilisateur a un mot de passe défini + if (!user.password) { + return res.status(401).json({ + success: false, + message: "Cet utilisateur n'a pas de mot de passe défini", + }); + } + + // Comparer le mot de passe + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: "Identifiant ou mot de passe incorrect", + }); + } + + // Mettre à jour la date de dernière connexion + await db + .update(users) + .set({ lastSignedIn: new Date() }) + .where(eq(users.id, user.id)); + + // Créer le token de session (compatible avec le système OAuth) + const token = await sdk.createSessionToken(user.openId, { + name: user.name || "", + expiresInMs: 7 * 24 * 60 * 60 * 1000, // 7 jours + }); + + // Définir le cookie de session + const cookieOptions = getSessionCookieOptions(req); + res.cookie(COOKIE_NAME, token, cookieOptions); + + // Retourner l'utilisateur (sans le mot de passe) + const { password: _, ...userWithoutPassword } = user; + return res.json({ + success: true, + user: userWithoutPassword, + }); + } catch (error) { + console.error("[LocalAuth] Error during login:", error); + return res.status(500).json({ + success: false, + message: "Erreur lors de la connexion", + }); + } +}); + +export default router; diff --git a/deployment-package/source_server/_core/map.ts b/deployment-package/source_server/_core/map.ts new file mode 100644 index 0000000..ac2c28c --- /dev/null +++ b/deployment-package/source_server/_core/map.ts @@ -0,0 +1,319 @@ +/** + * Google Maps API Integration for Manus WebDev Templates + * + * Main function: makeRequest(endpoint, params) - Makes authenticated requests to Google Maps APIs + * All credentials are automatically injected. Array parameters use | as separator. + * + * See API examples below the type definitions for usage patterns. + */ + +import { ENV } from "./env"; + +// ============================================================================ +// Configuration +// ============================================================================ + +type MapsConfig = { + baseUrl: string; + apiKey: string; +}; + +function getMapsConfig(): MapsConfig { + const baseUrl = ENV.forgeApiUrl; + const apiKey = ENV.forgeApiKey; + + if (!baseUrl || !apiKey) { + throw new Error( + "Google Maps proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY" + ); + } + + return { + baseUrl: baseUrl.replace(/\/+$/, ""), + apiKey, + }; +} + +// ============================================================================ +// Core Request Handler +// ============================================================================ + +interface RequestOptions { + method?: "GET" | "POST"; + body?: Record; +} + +/** + * Make authenticated requests to Google Maps APIs + * + * @param endpoint - The API endpoint (e.g., "/maps/api/geocode/json") + * @param params - Query parameters for the request + * @param options - Additional request options + * @returns The API response + */ +export async function makeRequest( + endpoint: string, + params: Record = {}, + options: RequestOptions = {} +): Promise { + const { baseUrl, apiKey } = getMapsConfig(); + + // Construct full URL: baseUrl + /v1/maps/proxy + endpoint + const url = new URL(`${baseUrl}/v1/maps/proxy${endpoint}`); + + // Add API key as query parameter (standard Google Maps API authentication) + url.searchParams.append("key", apiKey); + + // Add other query parameters + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + }); + + const response = await fetch(url.toString(), { + method: options.method || "GET", + headers: { + "Content-Type": "application/json", + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Google Maps API request failed (${response.status} ${response.statusText}): ${errorText}` + ); + } + + return (await response.json()) as T; +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +export type TravelMode = "driving" | "walking" | "bicycling" | "transit"; +export type MapType = "roadmap" | "satellite" | "terrain" | "hybrid"; +export type SpeedUnit = "KPH" | "MPH"; + +export type LatLng = { + lat: number; + lng: number; +}; + +export type DirectionsResult = { + routes: Array<{ + legs: Array<{ + distance: { text: string; value: number }; + duration: { text: string; value: number }; + start_address: string; + end_address: string; + start_location: LatLng; + end_location: LatLng; + steps: Array<{ + distance: { text: string; value: number }; + duration: { text: string; value: number }; + html_instructions: string; + travel_mode: string; + start_location: LatLng; + end_location: LatLng; + }>; + }>; + overview_polyline: { points: string }; + summary: string; + warnings: string[]; + waypoint_order: number[]; + }>; + status: string; +}; + +export type DistanceMatrixResult = { + rows: Array<{ + elements: Array<{ + distance: { text: string; value: number }; + duration: { text: string; value: number }; + status: string; + }>; + }>; + origin_addresses: string[]; + destination_addresses: string[]; + status: string; +}; + +export type GeocodingResult = { + results: Array<{ + address_components: Array<{ + long_name: string; + short_name: string; + types: string[]; + }>; + formatted_address: string; + geometry: { + location: LatLng; + location_type: string; + viewport: { + northeast: LatLng; + southwest: LatLng; + }; + }; + place_id: string; + types: string[]; + }>; + status: string; +}; + +export type PlacesSearchResult = { + results: Array<{ + place_id: string; + name: string; + formatted_address: string; + geometry: { + location: LatLng; + }; + rating?: number; + user_ratings_total?: number; + business_status?: string; + types: string[]; + }>; + status: string; +}; + +export type PlaceDetailsResult = { + result: { + place_id: string; + name: string; + formatted_address: string; + formatted_phone_number?: string; + international_phone_number?: string; + website?: string; + rating?: number; + user_ratings_total?: number; + reviews?: Array<{ + author_name: string; + rating: number; + text: string; + time: number; + }>; + opening_hours?: { + open_now: boolean; + weekday_text: string[]; + }; + geometry: { + location: LatLng; + }; + }; + status: string; +}; + +export type ElevationResult = { + results: Array<{ + elevation: number; + location: LatLng; + resolution: number; + }>; + status: string; +}; + +export type TimeZoneResult = { + dstOffset: number; + rawOffset: number; + status: string; + timeZoneId: string; + timeZoneName: string; +}; + +export type RoadsResult = { + snappedPoints: Array<{ + location: LatLng; + originalIndex?: number; + placeId: string; + }>; +}; + +// ============================================================================ +// Google Maps API Reference +// ============================================================================ + +/** + * GEOCODING - Convert between addresses and coordinates + * Endpoint: /maps/api/geocode/json + * Input: { address: string } OR { latlng: string } // latlng: "37.42,-122.08" + * Output: GeocodingResult // results[0].geometry.location, results[0].formatted_address + */ + +/** + * DIRECTIONS - Get navigation routes between locations + * Endpoint: /maps/api/directions/json + * Input: { origin: string, destination: string, mode?: TravelMode, waypoints?: string, alternatives?: boolean } + * Output: DirectionsResult // routes[0].legs[0].distance, duration, steps + */ + +/** + * DISTANCE MATRIX - Calculate travel times/distances for multiple origin-destination pairs + * Endpoint: /maps/api/distancematrix/json + * Input: { origins: string, destinations: string, mode?: TravelMode, units?: "metric"|"imperial" } // origins: "NYC|Boston" + * Output: DistanceMatrixResult // rows[0].elements[1] = first origin to second destination + */ + +/** + * PLACE SEARCH - Find businesses/POIs by text query + * Endpoint: /maps/api/place/textsearch/json + * Input: { query: string, location?: string, radius?: number, type?: string } // location: "40.7,-74.0" + * Output: PlacesSearchResult // results[].name, rating, geometry.location, place_id + */ + +/** + * NEARBY SEARCH - Find places near a specific location + * Endpoint: /maps/api/place/nearbysearch/json + * Input: { location: string, radius: number, type?: string, keyword?: string } // location: "40.7,-74.0" + * Output: PlacesSearchResult + */ + +/** + * PLACE DETAILS - Get comprehensive information about a specific place + * Endpoint: /maps/api/place/details/json + * Input: { place_id: string, fields?: string } // fields: "name,rating,opening_hours,website" + * Output: PlaceDetailsResult // result.name, rating, opening_hours, etc. + */ + +/** + * ELEVATION - Get altitude data for geographic points + * Endpoint: /maps/api/elevation/json + * Input: { locations?: string, path?: string, samples?: number } // locations: "39.73,-104.98|36.45,-116.86" + * Output: ElevationResult // results[].elevation (meters) + */ + +/** + * TIME ZONE - Get timezone information for a location + * Endpoint: /maps/api/timezone/json + * Input: { location: string, timestamp: number } // timestamp: Math.floor(Date.now()/1000) + * Output: TimeZoneResult // timeZoneId, timeZoneName + */ + +/** + * ROADS - Snap GPS traces to roads, find nearest roads, get speed limits + * - /v1/snapToRoads: Input: { path: string, interpolate?: boolean } // path: "lat,lng|lat,lng" + * - /v1/nearestRoads: Input: { points: string } // points: "lat,lng|lat,lng" + * - /v1/speedLimits: Input: { path: string, units?: SpeedUnit } + * Output: RoadsResult + */ + +/** + * PLACE AUTOCOMPLETE - Real-time place suggestions as user types + * Endpoint: /maps/api/place/autocomplete/json + * Input: { input: string, location?: string, radius?: number } + * Output: { predictions: Array<{ description: string, place_id: string }> } + */ + +/** + * STATIC MAPS - Generate map images as URLs (for emails, reports, tags) + * Endpoint: /maps/api/staticmap + * Input: URL params - center: string, zoom: number, size: string, markers?: string, maptype?: MapType + * Output: Image URL (not JSON) - use directly in + * Note: Construct URL manually with getMapsConfig() for auth + */ + + + + diff --git a/deployment-package/source_server/_core/notification.ts b/deployment-package/source_server/_core/notification.ts new file mode 100644 index 0000000..889a573 --- /dev/null +++ b/deployment-package/source_server/_core/notification.ts @@ -0,0 +1,114 @@ +import { TRPCError } from "@trpc/server"; +import { ENV } from "./env"; + +export type NotificationPayload = { + title: string; + content: string; +}; + +const TITLE_MAX_LENGTH = 1200; +const CONTENT_MAX_LENGTH = 20000; + +const trimValue = (value: string): string => value.trim(); +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +const buildEndpointUrl = (baseUrl: string): string => { + const normalizedBase = baseUrl.endsWith("/") + ? baseUrl + : `${baseUrl}/`; + return new URL( + "webdevtoken.v1.WebDevService/SendNotification", + normalizedBase + ).toString(); +}; + +const validatePayload = (input: NotificationPayload): NotificationPayload => { + if (!isNonEmptyString(input.title)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Notification title is required.", + }); + } + if (!isNonEmptyString(input.content)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Notification content is required.", + }); + } + + const title = trimValue(input.title); + const content = trimValue(input.content); + + if (title.length > TITLE_MAX_LENGTH) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`, + }); + } + + if (content.length > CONTENT_MAX_LENGTH) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`, + }); + } + + return { title, content }; +}; + +/** + * Dispatches a project-owner notification through the Manus Notification Service. + * Returns `true` if the request was accepted, `false` when the upstream service + * cannot be reached (callers can fall back to email/slack). Validation errors + * bubble up as TRPC errors so callers can fix the payload. + */ +export async function notifyOwner( + payload: NotificationPayload +): Promise { + const { title, content } = validatePayload(payload); + + if (!ENV.forgeApiUrl) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Notification service URL is not configured.", + }); + } + + if (!ENV.forgeApiKey) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Notification service API key is not configured.", + }); + } + + const endpoint = buildEndpointUrl(ENV.forgeApiUrl); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + accept: "application/json", + authorization: `Bearer ${ENV.forgeApiKey}`, + "content-type": "application/json", + "connect-protocol-version": "1", + }, + body: JSON.stringify({ title, content }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + console.warn( + `[Notification] Failed to notify owner (${response.status} ${response.statusText})${ + detail ? `: ${detail}` : "" + }` + ); + return false; + } + + return true; + } catch (error) { + console.warn("[Notification] Error calling notification service:", error); + return false; + } +} diff --git a/deployment-package/source_server/_core/oauth.ts b/deployment-package/source_server/_core/oauth.ts new file mode 100644 index 0000000..fd45373 --- /dev/null +++ b/deployment-package/source_server/_core/oauth.ts @@ -0,0 +1,53 @@ +import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; +import type { Express, Request, Response } from "express"; +import * as db from "../db"; +import { getSessionCookieOptions } from "./cookies"; +import { sdk } from "./sdk"; + +function getQueryParam(req: Request, key: string): string | undefined { + const value = req.query[key]; + return typeof value === "string" ? value : undefined; +} + +export function registerOAuthRoutes(app: Express) { + app.get("/api/oauth/callback", async (req: Request, res: Response) => { + const code = getQueryParam(req, "code"); + const state = getQueryParam(req, "state"); + + if (!code || !state) { + res.status(400).json({ error: "code and state are required" }); + return; + } + + try { + const tokenResponse = await sdk.exchangeCodeForToken(code, state); + const userInfo = await sdk.getUserInfo(tokenResponse.accessToken); + + if (!userInfo.openId) { + res.status(400).json({ error: "openId missing from user info" }); + return; + } + + await db.upsertUser({ + openId: userInfo.openId, + name: userInfo.name || null, + email: userInfo.email ?? null, + loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null, + lastSignedIn: new Date(), + }); + + const sessionToken = await sdk.createSessionToken(userInfo.openId, { + name: userInfo.name || "", + expiresInMs: ONE_YEAR_MS, + }); + + const cookieOptions = getSessionCookieOptions(req); + res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS }); + + res.redirect(302, "/"); + } catch (error) { + console.error("[OAuth] Callback failed", error); + res.status(500).json({ error: "OAuth callback failed" }); + } + }); +} diff --git a/deployment-package/source_server/_core/sdk.ts b/deployment-package/source_server/_core/sdk.ts new file mode 100644 index 0000000..956239b --- /dev/null +++ b/deployment-package/source_server/_core/sdk.ts @@ -0,0 +1,330 @@ +import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; +import { ForbiddenError } from "@shared/_core/errors"; +import axios, { type AxiosInstance } from "axios"; +import { parse as parseCookieHeader } from "cookie"; +import type { Request } from "express"; +import { SignJWT, jwtVerify } from "jose"; +import type { User } from "../../drizzle/schema"; +import * as db from "../db"; +import { ENV } from "./env"; +import type { + ExchangeTokenRequest, + ExchangeTokenResponse, + GetUserInfoResponse, + GetUserInfoWithJwtRequest, + GetUserInfoWithJwtResponse, +} from "./types/manusTypes"; +// Utility function +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +export type SessionPayload = { + openId: string; + appId: string; + name: string; +}; + +const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`; +const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`; +const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`; + +class OAuthService { + constructor(private client: ReturnType) { + console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl); + if (!ENV.oAuthServerUrl) { + console.error( + "[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable." + ); + } + } + + private decodeState(state: string): string { + const redirectUri = atob(state); + return redirectUri; + } + + async getTokenByCode( + code: string, + state: string + ): Promise { + const payload: ExchangeTokenRequest = { + clientId: ENV.appId, + grantType: "authorization_code", + code, + redirectUri: this.decodeState(state), + }; + + const { data } = await this.client.post( + EXCHANGE_TOKEN_PATH, + payload + ); + + return data; + } + + async getUserInfoByToken( + token: ExchangeTokenResponse + ): Promise { + const { data } = await this.client.post( + GET_USER_INFO_PATH, + { + accessToken: token.accessToken, + } + ); + + return data; + } +} + +const createOAuthHttpClient = (): AxiosInstance => + axios.create({ + baseURL: ENV.oAuthServerUrl, + timeout: AXIOS_TIMEOUT_MS, + }); + +class SDKServer { + private readonly client: AxiosInstance; + private readonly oauthService: OAuthService; + + constructor(client: AxiosInstance = createOAuthHttpClient()) { + this.client = client; + this.oauthService = new OAuthService(this.client); + } + + private deriveLoginMethod( + platforms: unknown, + fallback: string | null | undefined + ): string | null { + if (fallback && fallback.length > 0) return fallback; + if (!Array.isArray(platforms) || platforms.length === 0) return null; + const set = new Set( + platforms.filter((p): p is string => typeof p === "string") + ); + if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email"; + if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google"; + if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple"; + if ( + set.has("REGISTERED_PLATFORM_MICROSOFT") || + set.has("REGISTERED_PLATFORM_AZURE") + ) + return "microsoft"; + if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github"; + const first = Array.from(set)[0]; + return first ? first.toLowerCase() : null; + } + + /** + * Exchange OAuth authorization code for access token + * @example + * const tokenResponse = await sdk.exchangeCodeForToken(code, state); + */ + async exchangeCodeForToken( + code: string, + state: string + ): Promise { + return this.oauthService.getTokenByCode(code, state); + } + + /** + * Get user information using access token + * @example + * const userInfo = await sdk.getUserInfo(tokenResponse.accessToken); + */ + async getUserInfo(accessToken: string): Promise { + const data = await this.oauthService.getUserInfoByToken({ + accessToken, + } as ExchangeTokenResponse); + const loginMethod = this.deriveLoginMethod( + (data as any)?.platforms, + (data as any)?.platform ?? data.platform ?? null + ); + return { + ...(data as any), + platform: loginMethod, + loginMethod, + } as GetUserInfoResponse; + } + + private parseCookies(cookieHeader: string | undefined) { + if (!cookieHeader) { + return new Map(); + } + + const parsed = parseCookieHeader(cookieHeader); + return new Map(Object.entries(parsed)); + } + + private getSessionSecret() { + const secret = ENV.cookieSecret; + return new TextEncoder().encode(secret); + } + + /** + * Create a session token for a Manus user openId + * @example + * const sessionToken = await sdk.createSessionToken(userInfo.openId); + */ + async createSessionToken( + openId: string, + options: { expiresInMs?: number; name?: string } = {} + ): Promise { + return this.signSession( + { + openId, + appId: ENV.appId, + name: options.name || "", + }, + options + ); + } + + async signSession( + payload: SessionPayload, + options: { expiresInMs?: number } = {} + ): Promise { + const issuedAt = Date.now(); + const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS; + const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000); + const secretKey = this.getSessionSecret(); + + return new SignJWT({ + openId: payload.openId, + appId: payload.appId, + name: payload.name, + }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setExpirationTime(expirationSeconds) + .sign(secretKey); + } + + async verifySession( + cookieValue: string | undefined | null + ): Promise<{ openId: string; appId: string; name: string } | null> { + if (!cookieValue) { + console.warn("[Auth] Missing session cookie"); + return null; + } + + try { + const secretKey = this.getSessionSecret(); + const { payload } = await jwtVerify(cookieValue, secretKey, { + algorithms: ["HS256"], + }); + const { openId, appId, name } = payload as Record; + + if ( + !isNonEmptyString(openId) || + !isNonEmptyString(appId) || + !isNonEmptyString(name) + ) { + console.warn("[Auth] Session payload missing required fields"); + return null; + } + + return { + openId, + appId, + name, + }; + } catch (error) { + console.warn("[Auth] Session verification failed", String(error)); + return null; + } + } + + async getUserInfoWithJwt( + jwtToken: string + ): Promise { + const payload: GetUserInfoWithJwtRequest = { + jwtToken, + projectId: ENV.appId, + }; + + const { data } = await this.client.post( + GET_USER_INFO_WITH_JWT_PATH, + payload + ); + + const loginMethod = this.deriveLoginMethod( + (data as any)?.platforms, + (data as any)?.platform ?? data.platform ?? null + ); + return { + ...(data as any), + platform: loginMethod, + loginMethod, + } as GetUserInfoWithJwtResponse; + } + + async authenticateRequest(req: Request): Promise { + // Regular authentication flow + const cookies = this.parseCookies(req.headers.cookie); + const sessionCookie = cookies.get(COOKIE_NAME); + + // Try to verify as local JWT first + let session: { openId: string; appId?: string; name: string } | null = null; + let isLocalAuth = false; + + if (sessionCookie) { + try { + const secretKey = this.getSessionSecret(); + const { payload } = await jwtVerify(sessionCookie, secretKey, { + algorithms: ["HS256"], + }); + const { openId, name, appId } = payload as Record; + + // Check if it's a local JWT (has openId but may not have appId) + if (isNonEmptyString(openId) && isNonEmptyString(name)) { + session = { openId, name, appId: appId as string | undefined }; + isLocalAuth = !appId; // Local auth doesn't have appId + } + } catch (error) { + console.warn("[Auth] JWT verification failed:", error); + } + } + + // If local JWT verification failed, try OAuth session + if (!session) { + session = await this.verifySession(sessionCookie); + } + + if (!session) { + throw ForbiddenError("Invalid session cookie"); + } + + const sessionUserId = session.openId; + const signedInAt = new Date(); + let user = await db.getUserByOpenId(sessionUserId); + + // If user not in DB, sync from OAuth server automatically (only for OAuth sessions) + if (!user && !isLocalAuth) { + try { + const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? ""); + await db.upsertUser({ + openId: userInfo.openId, + name: userInfo.name || null, + email: userInfo.email ?? null, + loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null, + lastSignedIn: signedInAt, + }); + user = await db.getUserByOpenId(userInfo.openId); + } catch (error) { + console.error("[Auth] Failed to sync user from OAuth:", error); + throw ForbiddenError("Failed to sync user info"); + } + } + + if (!user) { + throw ForbiddenError("User not found"); + } + + await db.upsertUser({ + openId: user.openId, + lastSignedIn: signedInAt, + }); + + return user; + } +} + +export const sdk = new SDKServer(); diff --git a/deployment-package/source_server/_core/smtpSender.ts b/deployment-package/source_server/_core/smtpSender.ts new file mode 100644 index 0000000..64c0b30 --- /dev/null +++ b/deployment-package/source_server/_core/smtpSender.ts @@ -0,0 +1,103 @@ +/** + * Service d'envoi d'emails via SMTP avec nodemailer + */ + +import nodemailer from 'nodemailer'; +import type { Transporter } from 'nodemailer'; + +interface EmailParams { + to: string; + subject: string; + html: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; +} + +interface SMTPConfig { + host: string; + port: number; + secure: 'none' | 'tls' | 'ssl'; + user: string; + password: string; + fromEmail: string; + fromName: string; +} + +/** + * Crée un transporteur nodemailer à partir de la configuration SMTP + */ +function createTransporter(config: SMTPConfig): Transporter { + const secure = config.secure === 'ssl'; // true pour SSL (port 465), false pour TLS/STARTTLS + + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: secure, + auth: { + user: config.user, + pass: config.password, + }, + // Options supplémentaires pour améliorer la compatibilité + tls: { + // Ne pas échouer sur les certificats invalides (à utiliser avec précaution en production) + rejectUnauthorized: false, + }, + }); +} + +/** + * Envoie un email via SMTP + */ +export async function sendViaSMTP(params: EmailParams, config: SMTPConfig): Promise { + try { + const transporter = createTransporter(config); + + // Préparer les pièces jointes + const attachments = params.attachments?.map(att => ({ + filename: att.filename, + content: att.content, + contentType: att.contentType, + })); + + // Envoyer l'email + const info = await transporter.sendMail({ + from: `${config.fromName} <${config.fromEmail}>`, + to: params.to, + subject: params.subject, + html: params.html, + attachments: attachments, + }); + + console.log('[Email] Email envoyé via SMTP:', info.messageId); + return true; + } catch (error) { + console.error('[Email] Erreur lors de l\'envoi via SMTP:', error); + return false; + } +} + +/** + * Teste la connexion SMTP + */ +export async function testSMTPConnection(config: SMTPConfig): Promise<{ success: boolean; message: string }> { + try { + const transporter = createTransporter(config); + + // Vérifier la connexion + await transporter.verify(); + + return { + success: true, + message: 'Connexion SMTP réussie', + }; + } catch (error) { + console.error('[Email] Erreur de connexion SMTP:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Erreur de connexion SMTP', + }; + } +} diff --git a/deployment-package/source_server/_core/systemRouter.ts b/deployment-package/source_server/_core/systemRouter.ts new file mode 100644 index 0000000..babf6a4 --- /dev/null +++ b/deployment-package/source_server/_core/systemRouter.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { notifyOwner } from "./notification"; +import { adminProcedure, publicProcedure, router } from "./trpc"; + +export const systemRouter = router({ + health: publicProcedure + .input( + z.object({ + timestamp: z.number().min(0, "timestamp cannot be negative"), + }) + ) + .query(() => ({ + ok: true, + })), + + notifyOwner: adminProcedure + .input( + z.object({ + title: z.string().min(1, "title is required"), + content: z.string().min(1, "content is required"), + }) + ) + .mutation(async ({ input }) => { + const delivered = await notifyOwner(input); + return { + success: delivered, + } as const; + }), +}); diff --git a/deployment-package/source_server/_core/trpc.ts b/deployment-package/source_server/_core/trpc.ts new file mode 100644 index 0000000..a0bcc05 --- /dev/null +++ b/deployment-package/source_server/_core/trpc.ts @@ -0,0 +1,45 @@ +import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const'; +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import type { TrpcContext } from "./context"; + +const t = initTRPC.context().create({ + transformer: superjson, +}); + +export const router = t.router; +export const publicProcedure = t.procedure; + +const requireUser = t.middleware(async opts => { + const { ctx, next } = opts; + + if (!ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG }); + } + + return next({ + ctx: { + ...ctx, + user: ctx.user, + }, + }); +}); + +export const protectedProcedure = t.procedure.use(requireUser); + +export const adminProcedure = t.procedure.use( + t.middleware(async opts => { + const { ctx, next } = opts; + + if (!ctx.user || ctx.user.role !== 'admin') { + throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG }); + } + + return next({ + ctx: { + ...ctx, + user: ctx.user, + }, + }); + }), +); diff --git a/deployment-package/source_server/_core/types/cookie.d.ts b/deployment-package/source_server/_core/types/cookie.d.ts new file mode 100644 index 0000000..d6d4e88 --- /dev/null +++ b/deployment-package/source_server/_core/types/cookie.d.ts @@ -0,0 +1,6 @@ +declare module "cookie" { + export function parse( + str: string, + options?: Record + ): Record; +} diff --git a/deployment-package/source_server/_core/types/manusTypes.ts b/deployment-package/source_server/_core/types/manusTypes.ts new file mode 100644 index 0000000..89a3819 --- /dev/null +++ b/deployment-package/source_server/_core/types/manusTypes.ts @@ -0,0 +1,69 @@ +// WebDev Auth TypeScript types +// Auto-generated from protobuf definitions +// Generated on: 2025-09-24T05:57:57.338Z + +export interface AuthorizeRequest { + redirectUri: string; + projectId: string; + state: string; + responseType: string; + scope: string; +} + +export interface AuthorizeResponse { + redirectUrl: string; +} + +export interface ExchangeTokenRequest { + grantType: string; + code: string; + refreshToken?: string; + clientId: string; + clientSecret?: string; + redirectUri: string; +} + +export interface ExchangeTokenResponse { + accessToken: string; + tokenType: string; + expiresIn: number; + refreshToken?: string; + scope: string; + idToken: string; +} + +export interface GetUserInfoRequest { + accessToken: string; +} + +export interface GetUserInfoResponse { + openId: string; + projectId: string; + name: string; + email?: string | null; + platform?: string | null; + loginMethod?: string | null; +} + +export interface CanAccessRequest { + openId: string; + projectId: string; +} + +export interface CanAccessResponse { + canAccess: boolean; +} + +export interface GetUserInfoWithJwtRequest { + jwtToken: string; + projectId: string; +} + +export interface GetUserInfoWithJwtResponse { + openId: string; + projectId: string; + name: string; + email?: string | null; + platform?: string | null; + loginMethod?: string | null; +} diff --git a/deployment-package/source_server/_core/uploadFile.ts b/deployment-package/source_server/_core/uploadFile.ts new file mode 100644 index 0000000..af6351a --- /dev/null +++ b/deployment-package/source_server/_core/uploadFile.ts @@ -0,0 +1,67 @@ +import { Router } from "express"; +import multer from "multer"; +import { storagePut } from "../storage"; +import { randomBytes } from "crypto"; +import path from "path"; + +const router = Router(); + +// Configuration de multer pour gérer les uploads en mémoire +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB max + }, +}); + +/** + * Route d'upload de fichiers vers S3 + * POST /api/upload-file + * Body: multipart/form-data avec un champ "file" + */ +router.post("/", upload.single("file"), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ + success: false, + message: "Aucun fichier fourni", + }); + } + + const file = req.file; + + // Générer un nom de fichier unique + const randomSuffix = randomBytes(8).toString("hex"); + const ext = path.extname(file.originalname); + const baseName = path.basename(file.originalname, ext); + const safeBaseName = baseName.replace(/[^a-zA-Z0-9-_]/g, "_"); + const fileName = `rappels-attachments/${Date.now()}-${randomSuffix}-${safeBaseName}${ext}`; + + // Upload vers S3 + const result = await storagePut( + fileName, + file.buffer, + file.mimetype + ); + + console.log(`[UploadFile] Fichier uploadé: ${file.originalname} (${file.size} octets)`); + + return res.json({ + success: true, + url: result.url, + key: fileName, + filename: file.originalname, + mimetype: file.mimetype, + size: file.size, + message: "Fichier uploadé avec succès", + }); + } catch (error) { + console.error("[UploadFile] Error:", error); + return res.status(500).json({ + success: false, + message: "Erreur lors de l'upload du fichier", + }); + } +}); + +export default router; diff --git a/deployment-package/source_server/_core/uploadImage.ts b/deployment-package/source_server/_core/uploadImage.ts new file mode 100644 index 0000000..78f0c01 --- /dev/null +++ b/deployment-package/source_server/_core/uploadImage.ts @@ -0,0 +1,67 @@ +import { Router } from "express"; +import multer from "multer"; +import { storagePut } from "../storage"; +import { randomBytes } from "crypto"; + +const router = Router(); + +// Configuration de multer pour gérer les uploads en mémoire +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 5 * 1024 * 1024, // 5MB max + }, + fileFilter: (req, file, cb) => { + // Vérifier que c'est bien une image + if (file.mimetype.startsWith("image/")) { + cb(null, true); + } else { + cb(new Error("Le fichier doit être une image")); + } + }, +}); + +/** + * Route d'upload d'images vers S3 + * POST /api/upload-image + * Body: multipart/form-data avec un champ "file" + */ +router.post("/", upload.single("file"), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ + success: false, + message: "Aucun fichier fourni", + }); + } + + const file = req.file; + + // Générer un nom de fichier unique + const randomSuffix = randomBytes(8).toString("hex"); + const extension = file.originalname.split(".").pop() || "jpg"; + const fileName = `attestations/${Date.now()}-${randomSuffix}.${extension}`; + + // Upload vers S3 + const result = await storagePut( + fileName, + file.buffer, + file.mimetype + ); + + return res.json({ + success: true, + url: result.url, + s3Key: fileName, + message: "Image uploadée avec succès", + }); + } catch (error) { + console.error("[UploadImage] Error:", error); + return res.status(500).json({ + success: false, + message: "Erreur lors de l'upload de l'image", + }); + } +}); + +export default router; diff --git a/deployment-package/source_server/_core/vite.ts b/deployment-package/source_server/_core/vite.ts new file mode 100644 index 0000000..86d07f6 --- /dev/null +++ b/deployment-package/source_server/_core/vite.ts @@ -0,0 +1,67 @@ +import express, { type Express } from "express"; +import fs from "fs"; +import { type Server } from "http"; +import { nanoid } from "nanoid"; +import path from "path"; +import { createServer as createViteServer } from "vite"; +import viteConfig from "../../vite.config"; + +export async function setupVite(app: Express, server: Server) { + const serverOptions = { + middlewareMode: true, + hmr: { server }, + allowedHosts: true as const, + }; + + const vite = await createViteServer({ + ...viteConfig, + configFile: false, + server: serverOptions, + appType: "custom", + }); + + app.use(vite.middlewares); + app.use("*", async (req, res, next) => { + const url = req.originalUrl; + + try { + const clientTemplate = path.resolve( + import.meta.dirname, + "../..", + "client", + "index.html" + ); + + // always reload the index.html file from disk incase it changes + let template = await fs.promises.readFile(clientTemplate, "utf-8"); + template = template.replace( + `src="/src/main.tsx"`, + `src="/src/main.tsx?v=${nanoid()}"` + ); + const page = await vite.transformIndexHtml(url, template); + res.status(200).set({ "Content-Type": "text/html" }).end(page); + } catch (e) { + vite.ssrFixStacktrace(e as Error); + next(e); + } + }); +} + +export function serveStatic(app: Express) { + const distPath = + process.env.NODE_ENV === "development" + ? path.resolve(import.meta.dirname, "../..", "dist", "public") + : path.resolve(import.meta.dirname, "public"); + if (!fs.existsSync(distPath)) { + console.error( + `Could not find the build directory: ${distPath}, make sure to build the client first` + ); + } + + app.use(express.static(distPath)); + + // fall through to index.html if the file doesn't exist + app.use("*", (_req, res) => { + res.sendFile(path.resolve(distPath, "index.html")); + }); +} diff --git a/deployment-package/source_server/_core/voiceTranscription.ts b/deployment-package/source_server/_core/voiceTranscription.ts new file mode 100644 index 0000000..ed511f6 --- /dev/null +++ b/deployment-package/source_server/_core/voiceTranscription.ts @@ -0,0 +1,284 @@ +/** + * Voice transcription helper using internal Speech-to-Text service + * + * Frontend implementation guide: + * 1. Capture audio using MediaRecorder API + * 2. Upload audio to storage (e.g., S3) to get URL + * 3. Call transcription with the URL + * + * Example usage: + * ```tsx + * // Frontend component + * const transcribeMutation = trpc.voice.transcribe.useMutation({ + * onSuccess: (data) => { + * console.log(data.text); // Full transcription + * console.log(data.language); // Detected language + * console.log(data.segments); // Timestamped segments + * } + * }); + * + * // After uploading audio to storage + * transcribeMutation.mutate({ + * audioUrl: uploadedAudioUrl, + * language: 'en', // optional + * prompt: 'Transcribe the meeting' // optional + * }); + * ``` + */ +import { ENV } from "./env"; + +export type TranscribeOptions = { + audioUrl: string; // URL to the audio file (e.g., S3 URL) + language?: string; // Optional: specify language code (e.g., "en", "es", "zh") + prompt?: string; // Optional: custom prompt for the transcription +}; + +// Native Whisper API segment format +export type WhisperSegment = { + id: number; + seek: number; + start: number; + end: number; + text: string; + tokens: number[]; + temperature: number; + avg_logprob: number; + compression_ratio: number; + no_speech_prob: number; +}; + +// Native Whisper API response format +export type WhisperResponse = { + task: "transcribe"; + language: string; + duration: number; + text: string; + segments: WhisperSegment[]; +}; + +export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly + +export type TranscriptionError = { + error: string; + code: "FILE_TOO_LARGE" | "INVALID_FORMAT" | "TRANSCRIPTION_FAILED" | "UPLOAD_FAILED" | "SERVICE_ERROR"; + details?: string; +}; + +/** + * Transcribe audio to text using the internal Speech-to-Text service + * + * @param options - Audio data and metadata + * @returns Transcription result or error + */ +export async function transcribeAudio( + options: TranscribeOptions +): Promise { + try { + // Step 1: Validate environment configuration + if (!ENV.forgeApiUrl) { + return { + error: "Voice transcription service is not configured", + code: "SERVICE_ERROR", + details: "BUILT_IN_FORGE_API_URL is not set" + }; + } + if (!ENV.forgeApiKey) { + return { + error: "Voice transcription service authentication is missing", + code: "SERVICE_ERROR", + details: "BUILT_IN_FORGE_API_KEY is not set" + }; + } + + // Step 2: Download audio from URL + let audioBuffer: Buffer; + let mimeType: string; + try { + const response = await fetch(options.audioUrl); + if (!response.ok) { + return { + error: "Failed to download audio file", + code: "INVALID_FORMAT", + details: `HTTP ${response.status}: ${response.statusText}` + }; + } + + audioBuffer = Buffer.from(await response.arrayBuffer()); + mimeType = response.headers.get('content-type') || 'audio/mpeg'; + + // Check file size (16MB limit) + const sizeMB = audioBuffer.length / (1024 * 1024); + if (sizeMB > 16) { + return { + error: "Audio file exceeds maximum size limit", + code: "FILE_TOO_LARGE", + details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB` + }; + } + } catch (error) { + return { + error: "Failed to fetch audio file", + code: "SERVICE_ERROR", + details: error instanceof Error ? error.message : "Unknown error" + }; + } + + // Step 3: Create FormData for multipart upload to Whisper API + const formData = new FormData(); + + // Create a Blob from the buffer and append to form + const filename = `audio.${getFileExtension(mimeType)}`; + const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType }); + formData.append("file", audioBlob, filename); + + formData.append("model", "whisper-1"); + formData.append("response_format", "verbose_json"); + + // Add prompt - use custom prompt if provided, otherwise generate based on language + const prompt = options.prompt || ( + options.language + ? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}` + : "Transcribe the user's voice to text" + ); + formData.append("prompt", prompt); + + // Step 4: Call the transcription service + const baseUrl = ENV.forgeApiUrl.endsWith("/") + ? ENV.forgeApiUrl + : `${ENV.forgeApiUrl}/`; + + const fullUrl = new URL( + "v1/audio/transcriptions", + baseUrl + ).toString(); + + const response = await fetch(fullUrl, { + method: "POST", + headers: { + authorization: `Bearer ${ENV.forgeApiKey}`, + "Accept-Encoding": "identity", + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + return { + error: "Transcription service request failed", + code: "TRANSCRIPTION_FAILED", + details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}` + }; + } + + // Step 5: Parse and return the transcription result + const whisperResponse = await response.json() as WhisperResponse; + + // Validate response structure + if (!whisperResponse.text || typeof whisperResponse.text !== 'string') { + return { + error: "Invalid transcription response", + code: "SERVICE_ERROR", + details: "Transcription service returned an invalid response format" + }; + } + + return whisperResponse; // Return native Whisper API response directly + + } catch (error) { + // Handle unexpected errors + return { + error: "Voice transcription failed", + code: "SERVICE_ERROR", + details: error instanceof Error ? error.message : "An unexpected error occurred" + }; + } +} + +/** + * Helper function to get file extension from MIME type + */ +function getFileExtension(mimeType: string): string { + const mimeToExt: Record = { + 'audio/webm': 'webm', + 'audio/mp3': 'mp3', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/wave': 'wav', + 'audio/ogg': 'ogg', + 'audio/m4a': 'm4a', + 'audio/mp4': 'm4a', + }; + + return mimeToExt[mimeType] || 'audio'; +} + +/** + * Helper function to get full language name from ISO code + */ +function getLanguageName(langCode: string): string { + const langMap: Record = { + 'en': 'English', + 'es': 'Spanish', + 'fr': 'French', + 'de': 'German', + 'it': 'Italian', + 'pt': 'Portuguese', + 'ru': 'Russian', + 'ja': 'Japanese', + 'ko': 'Korean', + 'zh': 'Chinese', + 'ar': 'Arabic', + 'hi': 'Hindi', + 'nl': 'Dutch', + 'pl': 'Polish', + 'tr': 'Turkish', + 'sv': 'Swedish', + 'da': 'Danish', + 'no': 'Norwegian', + 'fi': 'Finnish', + }; + + return langMap[langCode] || langCode; +} + +/** + * Example tRPC procedure implementation: + * + * ```ts + * // In server/routers.ts + * import { transcribeAudio } from "./_core/voiceTranscription"; + * + * export const voiceRouter = router({ + * transcribe: protectedProcedure + * .input(z.object({ + * audioUrl: z.string(), + * language: z.string().optional(), + * prompt: z.string().optional(), + * })) + * .mutation(async ({ input, ctx }) => { + * const result = await transcribeAudio(input); + * + * // Check if it's an error + * if ('error' in result) { + * throw new TRPCError({ + * code: 'BAD_REQUEST', + * message: result.error, + * cause: result, + * }); + * } + * + * // Optionally save transcription to database + * await db.insert(transcriptions).values({ + * userId: ctx.user.id, + * text: result.text, + * duration: result.duration, + * language: result.language, + * audioUrl: input.audioUrl, + * createdAt: new Date(), + * }); + * + * return result; + * }), + * }); + * ``` + */ diff --git a/deployment-package/source_server/analyticsDb.ts b/deployment-package/source_server/analyticsDb.ts new file mode 100644 index 0000000..5f1bcc4 --- /dev/null +++ b/deployment-package/source_server/analyticsDb.ts @@ -0,0 +1,147 @@ +/** + * Requêtes analytiques pour le tableau de bord + */ + +import { eq, sql, and, gte, lte, desc } from "drizzle-orm"; +import { getDb } from "./db"; +import { inscriptions, sequences, apprenants, datesFormation, formations } from "../drizzle/schema"; + +/** + * Récupère les statistiques d'inscriptions par mois + * @param startDate - Date de début de la période + * @param endDate - Date de fin de la période + */ +export async function getInscriptionsByMonth(startDate?: Date, endDate?: Date) { + const db = await getDb(); + if (!db) return []; + + const conditions = []; + if (startDate) { + conditions.push(gte(inscriptions.dateInscription, startDate)); + } + if (endDate) { + conditions.push(lte(inscriptions.dateInscription, endDate)); + } + + const result = await db + .select({ + mois: sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`, + total: sql`COUNT(*)`, + confirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + listeAttente: sql`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`, + annulees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`, + }) + .from(inscriptions) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .groupBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`) + .orderBy(sql`DATE_FORMAT(${inscriptions.dateInscription}, '%Y-%m')`); + + return result; +} + +/** + * Récupère le taux de remplissage des séquences par mois + */ +export async function getTauxRemplissageByMonth() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + mois: sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`, + capaciteTotale: sql`SUM(${sequences.capaciteMax})`, + inscritsConfirmes: sql`SUM( + (SELECT COUNT(*) + FROM ${inscriptions} + WHERE ${inscriptions.sequenceId} = ${sequences.id} + AND ${inscriptions.statut} = 'confirmee') + )`, + tauxRemplissage: sql`ROUND( + (SUM( + (SELECT COUNT(*) + FROM ${inscriptions} + WHERE ${inscriptions.sequenceId} = ${sequences.id} + AND ${inscriptions.statut} = 'confirmee') + ) / SUM(${sequences.capaciteMax})) * 100, + 2 + )`, + }) + .from(sequences) + .groupBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`) + .orderBy(sql`DATE_FORMAT(${sequences.createdAt}, '%Y-%m')`); + + return result; +} + +/** + * Récupère les statistiques de participation par établissement + */ +export async function getParticipationByEtablissement() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + codeEtablissement: apprenants.codeEtablissement, + totalApprenants: sql`COUNT(DISTINCT ${apprenants.id})`, + totalInscriptions: sql`COUNT(${inscriptions.id})`, + inscriptionsConfirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + inscriptionsListeAttente: sql`SUM(CASE WHEN ${inscriptions.statut} = 'liste_attente' THEN 1 ELSE 0 END)`, + inscriptionsAnnulees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'annulee' THEN 1 ELSE 0 END)`, + }) + .from(apprenants) + .leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId)) + .groupBy(apprenants.codeEtablissement) + .orderBy(desc(sql`COUNT(${inscriptions.id})`)); + + return result; +} + +/** + * Récupère les statistiques globales du tableau de bord + */ +export async function getGlobalStats() { + const db = await getDb(); + if (!db) return null; + + const [stats] = await db + .select({ + totalFormations: sql`(SELECT COUNT(*) FROM ${formations} WHERE ${formations.actif} = 1)`, + totalSequences: sql`(SELECT COUNT(*) FROM ${sequences})`, + sequencesOuvertes: sql`(SELECT COUNT(*) FROM ${sequences} WHERE ${sequences.statut} = 'ouverte')`, + totalApprenants: sql`(SELECT COUNT(*) FROM ${apprenants})`, + totalInscriptions: sql`(SELECT COUNT(*) FROM ${inscriptions})`, + inscriptionsConfirmees: sql`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee')`, + inscriptionsListeAttente: sql`(SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'liste_attente')`, + tauxRemplissageMoyen: sql`ROUND( + (SELECT COUNT(*) FROM ${inscriptions} WHERE ${inscriptions.statut} = 'confirmee') / + (SELECT SUM(${sequences.capaciteMax}) FROM ${sequences}) * 100, + 2 + )`, + }) + .from(sql`dual`); + + return stats; +} + +/** + * Récupère les statistiques de participation par fonction + */ +export async function getParticipationByFonction() { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + fonction: apprenants.fonction, + totalApprenants: sql`COUNT(DISTINCT ${apprenants.id})`, + totalInscriptions: sql`COUNT(${inscriptions.id})`, + inscriptionsConfirmees: sql`SUM(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 ELSE 0 END)`, + }) + .from(apprenants) + .leftJoin(inscriptions, eq(apprenants.id, inscriptions.apprenantId)) + .groupBy(apprenants.fonction) + .orderBy(desc(sql`COUNT(${inscriptions.id})`)); + + return result; +} diff --git a/deployment-package/source_server/analyticsExportService.ts b/deployment-package/source_server/analyticsExportService.ts new file mode 100644 index 0000000..392774c --- /dev/null +++ b/deployment-package/source_server/analyticsExportService.ts @@ -0,0 +1,145 @@ +/** + * Service d'export des rapports analytiques en PDF et Excel + */ + +import ExcelJS from 'exceljs'; +import { getGlobalStats, getInscriptionsByMonth, getTauxRemplissageByMonth, getParticipationByEtablissement, getParticipationByFonction } from './analyticsDb'; + +/** + * Génère un rapport Excel des statistiques analytiques + */ +export async function generateAnalyticsExcel(startDate?: Date, endDate?: Date): Promise { + const workbook = new ExcelJS.Workbook(); + + // Récupérer toutes les données + const globalStats = await getGlobalStats(); + const inscriptionsByMonth = await getInscriptionsByMonth(startDate, endDate); + const tauxRemplissage = await getTauxRemplissageByMonth(); + const participationEtablissement = await getParticipationByEtablissement(); + const participationFonction = await getParticipationByFonction(); + + // Feuille 1: Statistiques globales + const statsSheet = workbook.addWorksheet('Statistiques globales'); + statsSheet.columns = [ + { header: 'Indicateur', key: 'indicateur', width: 30 }, + { header: 'Valeur', key: 'valeur', width: 15 }, + ]; + + if (globalStats) { + statsSheet.addRows([ + { indicateur: 'Total Formations actives', valeur: globalStats.totalFormations }, + { indicateur: 'Total Séquences', valeur: globalStats.totalSequences }, + { indicateur: 'Séquences ouvertes', valeur: globalStats.sequencesOuvertes }, + { indicateur: 'Total Apprenants', valeur: globalStats.totalApprenants }, + { indicateur: 'Total Inscriptions', valeur: globalStats.totalInscriptions }, + { indicateur: 'Inscriptions confirmées', valeur: globalStats.inscriptionsConfirmees }, + { indicateur: 'Inscriptions en liste d\'attente', valeur: globalStats.inscriptionsListeAttente }, + { indicateur: 'Taux de remplissage moyen (%)', valeur: globalStats.tauxRemplissageMoyen }, + ]); + } + + // Feuille 2: Inscriptions par mois + const inscriptionsSheet = workbook.addWorksheet('Inscriptions par mois'); + inscriptionsSheet.columns = [ + { header: 'Mois', key: 'mois', width: 15 }, + { header: 'Total', key: 'total', width: 12 }, + { header: 'Confirmées', key: 'confirmees', width: 12 }, + { header: 'Liste d\'attente', key: 'listeAttente', width: 15 }, + { header: 'Annulées', key: 'annulees', width: 12 }, + ]; + + inscriptionsByMonth.forEach(item => { + inscriptionsSheet.addRow({ + mois: item.mois, + total: Number(item.total), + confirmees: Number(item.confirmees), + listeAttente: Number(item.listeAttente), + annulees: Number(item.annulees), + }); + }); + + // Feuille 3: Taux de remplissage + const tauxSheet = workbook.addWorksheet('Taux de remplissage'); + tauxSheet.columns = [ + { header: 'Mois', key: 'mois', width: 15 }, + { header: 'Capacité totale', key: 'capaciteTotale', width: 15 }, + { header: 'Inscrits confirmés', key: 'inscritsConfirmes', width: 18 }, + { header: 'Taux de remplissage (%)', key: 'tauxRemplissage', width: 20 }, + ]; + + tauxRemplissage.forEach(item => { + tauxSheet.addRow({ + mois: item.mois, + capaciteTotale: Number(item.capaciteTotale), + inscritsConfirmes: Number(item.inscritsConfirmes), + tauxRemplissage: Number(item.tauxRemplissage), + }); + }); + + // Feuille 4: Participation par établissement + const etablissementSheet = workbook.addWorksheet('Participation par établissement'); + etablissementSheet.columns = [ + { header: 'Code établissement', key: 'codeEtablissement', width: 20 }, + { header: 'Total apprenants', key: 'totalApprenants', width: 15 }, + { header: 'Total inscriptions', key: 'totalInscriptions', width: 18 }, + { header: 'Confirmées', key: 'confirmees', width: 12 }, + { header: 'Liste d\'attente', key: 'listeAttente', width: 15 }, + { header: 'Annulées', key: 'annulees', width: 12 }, + ]; + + participationEtablissement.forEach(item => { + etablissementSheet.addRow({ + codeEtablissement: item.codeEtablissement, + totalApprenants: Number(item.totalApprenants), + totalInscriptions: Number(item.totalInscriptions), + confirmees: Number(item.inscriptionsConfirmees), + listeAttente: Number(item.inscriptionsListeAttente), + annulees: Number(item.inscriptionsAnnulees), + }); + }); + + // Feuille 5: Participation par fonction + const fonctionSheet = workbook.addWorksheet('Participation par fonction'); + fonctionSheet.columns = [ + { header: 'Fonction', key: 'fonction', width: 20 }, + { header: 'Total apprenants', key: 'totalApprenants', width: 15 }, + { header: 'Total inscriptions', key: 'totalInscriptions', width: 18 }, + { header: 'Confirmées', key: 'confirmees', width: 12 }, + ]; + + participationFonction.forEach(item => { + fonctionSheet.addRow({ + fonction: item.fonction === 'directeur' ? 'Directeurs' : + item.fonction === 'chef_service' ? 'Chefs de service' : 'Autres', + totalApprenants: Number(item.totalApprenants), + totalInscriptions: Number(item.totalInscriptions), + confirmees: Number(item.inscriptionsConfirmees), + }); + }); + + // Styliser les en-têtes + [statsSheet, inscriptionsSheet, tauxSheet, etablissementSheet, fonctionSheet].forEach(sheet => { + sheet.getRow(1).font = { bold: true }; + sheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF3B82F6' }, + }; + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + }); + + // Générer le buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +/** + * Génère un rapport PDF des statistiques analytiques + * Note: Cette fonction retourne actuellement un placeholder + * Une implémentation complète nécessiterait une bibliothèque comme puppeteer ou pdfkit + */ +export async function generateAnalyticsPDF(startDate?: Date, endDate?: Date): Promise { + // Pour l'instant, retourner un message indiquant que la fonctionnalité est en développement + // Une implémentation complète nécessiterait de générer un HTML et de le convertir en PDF + throw new Error("L'export PDF sera implémenté dans une prochaine version"); +} diff --git a/deployment-package/source_server/attestationService.ts b/deployment-package/source_server/attestationService.ts new file mode 100644 index 0000000..b8a2dad --- /dev/null +++ b/deployment-package/source_server/attestationService.ts @@ -0,0 +1,430 @@ +import { getDb } from "./db"; +import { attestations, configAttestation, inscriptions, apprenants, sequences, formations, datesFormation } from "../drizzle/schema"; +import { eq, and } from "drizzle-orm"; +import { storagePut } from "./storage"; +import PDFDocument from "pdfkit"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import crypto from "crypto"; + +/** + * Génère une attestation de formation en PDF + */ +export async function genererAttestationPDF(inscriptionId: number): Promise<{ s3Key: string; pdfUrl: string }> { + const db = await getDb(); + if (!db) { + throw new Error("Base de données non disponible"); + } + + // Récupérer les données de l'inscription + const [inscriptionData] = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + sequence: sequences, + formation: formations, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .where(eq(inscriptions.id, inscriptionId)) + .limit(1); + + if (!inscriptionData) { + throw new Error("Inscription introuvable"); + } + + const { inscription, apprenant, sequence, formation } = inscriptionData; + + // Récupérer les dates de la séquence + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequence.id)); + + // Récupérer la configuration de l'attestation + const [config] = await db + .select() + .from(configAttestation) + .limit(1); + + // Créer le PDF + const doc = new PDFDocument({ + size: "A4", + margins: { top: 50, bottom: 50, left: 50, right: 50 }, + }); + + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + + return new Promise((resolve, reject) => { + doc.on("end", async () => { + try { + const pdfBuffer = Buffer.concat(chunks); + + // Générer une clé S3 unique + const randomSuffix = crypto.randomBytes(8).toString("hex"); + const s3Key = `attestations/${apprenant.id}-${sequence.id}-${randomSuffix}.pdf`; + + // Upload vers S3 + const { url } = await storagePut(s3Key, pdfBuffer, "application/pdf"); + + resolve({ s3Key, pdfUrl: url }); + } catch (error) { + reject(error); + } + }); + + doc.on("error", reject); + + // Construction du PDF + (async () => { + try { + // Logo en haut à droite si disponible + if (config?.logoUrl) { + try { + const logoResponse = await fetch(config.logoUrl); + const logoBuffer = Buffer.from(await logoResponse.arrayBuffer()); + doc.image(logoBuffer, doc.page.width - 150, 50, { width: 100 }); + } catch (error) { + console.error("Erreur lors du chargement du logo:", error); + } + } + + // Titre + doc.fontSize(24).font("Helvetica-Bold").text("ATTESTATION DE FORMATION", { align: "center" }); + doc.moveDown(2); + + // Texte personnalisable ou texte par défaut + const texteAttestation = config?.texteAttestation || + "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}."; + + // Remplacer les variables + const nomComplet = `${apprenant.prenom} ${apprenant.nom}`; + const dateDebut = dates.length > 0 ? format(new Date(dates[0].dateDebut), "dd MMMM yyyy", { locale: fr }) : ""; + const dateFin = dates.length > 0 ? format(new Date(dates[dates.length - 1].dateFin), "dd MMMM yyyy", { locale: fr }) : ""; + + const texteRempli = texteAttestation + .replace(/\{\{nomComplet\}\}/g, nomComplet) + .replace(/\{\{nomFormation\}\}/g, formation.nom) + .replace(/\{\{dateDebut\}\}/g, dateDebut) + .replace(/\{\{dateFin\}\}/g, dateFin); + + doc.fontSize(12).font("Helvetica").text(texteRempli, { align: "justify" }); + doc.moveDown(2); + + // Détails de la formation + doc.fontSize(10).font("Helvetica-Bold").text("Détails de la formation :", { underline: true }); + doc.moveDown(0.5); + doc.font("Helvetica"); + doc.text(`Formation : ${formation.nom}`); + doc.text(`Séquence : ${sequence.nom}`); + doc.text(`Lieu : ${sequence.lieu}`); + doc.moveDown(0.5); + doc.text("Dates :"); + dates.forEach((date) => { + const dateStr = format(new Date(date.dateDebut), "dd/MM/yyyy", { locale: fr }); + const heureDebut = format(new Date(date.dateDebut), "HH:mm"); + const heureFin = format(new Date(date.dateFin), "HH:mm"); + doc.text(` • ${dateStr} de ${heureDebut} à ${heureFin}`); + }); + + doc.moveDown(3); + + // Signature + doc.fontSize(10); + doc.text(`Fait le ${format(new Date(), "dd MMMM yyyy", { locale: fr })}`, { align: "right" }); + doc.moveDown(3); + + // Signature si disponible + if (config?.signatureUrl) { + try { + const signatureResponse = await fetch(config.signatureUrl); + const signatureBuffer = Buffer.from(await signatureResponse.arrayBuffer()); + const signatureX = doc.page.width - 200; + const signatureY = doc.y; + doc.image(signatureBuffer, signatureX, signatureY, { width: 150, height: 50 }); + doc.moveDown(3); + } catch (error) { + console.error("Erreur lors du chargement de la signature:", error); + } + } + + if (config?.nomSignataire) { + doc.text(config.nomSignataire, { align: "right" }); + } + if (config?.fonctionSignataire) { + doc.text(config.fonctionSignataire, { align: "right" }); + } + + doc.end(); + } catch (error) { + doc.end(); + reject(error); + } + })(); + }); +} + +/** + * Enregistre une attestation dans la base de données + */ +export async function enregistrerAttestation( + inscriptionId: number, + apprenantId: number, + sequenceId: number, + s3Key: string, + pdfUrl: string +): Promise { + const db = await getDb(); + if (!db) { + throw new Error("Base de données non disponible"); + } + + const [result] = await db.insert(attestations).values({ + inscriptionId, + apprenantId, + sequenceId, + s3Key, + pdfUrl, + emailEnvoye: false, + }); + + return result.insertId; +} + +/** + * Récupère l'attestation d'un apprenant pour une séquence + */ +export async function getAttestation(apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return null; + + const [attestation] = await db + .select() + .from(attestations) + .where( + and( + eq(attestations.apprenantId, apprenantId), + eq(attestations.sequenceId, sequenceId) + ) + ) + .limit(1); + + return attestation || null; +} + +/** + * Récupère toutes les attestations d'un apprenant + */ +export async function getAttestationsApprenant(apprenantId: number) { + const db = await getDb(); + if (!db) return []; + + return db + .select({ + attestation: attestations, + sequence: sequences, + formation: formations, + }) + .from(attestations) + .innerJoin(sequences, eq(attestations.sequenceId, sequences.id)) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .where(eq(attestations.apprenantId, apprenantId)); +} + +/** + * Marque une attestation comme envoyée par email + */ +export async function marquerAttestationEnvoyee(attestationId: number) { + const db = await getDb(); + if (!db) return; + + await db + .update(attestations) + .set({ + emailEnvoye: true, + dateEnvoiEmail: new Date(), + }) + .where(eq(attestations.id, attestationId)); +} + +/** + * Récupère ou crée la configuration des attestations + */ +export async function getOrCreateConfigAttestation() { + const db = await getDb(); + if (!db) return null; + + let [config] = await db.select().from(configAttestation).limit(1); + + if (!config) { + // Créer une configuration par défaut + await db.insert(configAttestation).values({ + texteAttestation: "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}.", + }); + + [config] = await db.select().from(configAttestation).limit(1); + } + + return config; +} + +/** + * Met à jour la configuration des attestations + */ +export async function updateConfigAttestation(data: { + logoS3Key?: string; + logoUrl?: string; + signatureS3Key?: string; + signatureUrl?: string; + nomSignataire?: string; + fonctionSignataire?: string; + texteAttestation?: string; +}) { + const db = await getDb(); + if (!db) return; + + const [existing] = await db.select().from(configAttestation).limit(1); + + if (existing) { + await db.update(configAttestation).set(data).where(eq(configAttestation.id, existing.id)); + } else { + await db.insert(configAttestation).values(data); + } +} + +/** + * Génère une prévisualisation du modèle d'attestation avec des données fictives + */ +export async function genererPreviewAttestation(): Promise<{ pdfUrl: string }> { + const db = await getDb(); + if (!db) { + throw new Error("Base de données non disponible"); + } + + // Récupérer la configuration de l'attestation + const config = await getOrCreateConfigAttestation(); + if (!config) { + throw new Error("Configuration d'attestation introuvable"); + } + + // Données fictives pour la prévisualisation + const donneesFictives = { + apprenant: { + nom: "Dupont", + prenom: "Jean", + email: "jean.dupont@example.com", + }, + formation: { + nom: "Formation Manager Itinova", + nbJours: 5, + }, + sequence: { + nom: "Groupe A", + lieu: "Paris", + }, + dates: [ + { date: new Date("2026-02-10") }, + { date: new Date("2026-02-11") }, + { date: new Date("2026-02-12") }, + { date: new Date("2026-02-13") }, + { date: new Date("2026-02-14") }, + ], + }; + + // Créer le PDF + const doc = new PDFDocument({ size: "A4", margin: 50 }); + const chunks: Buffer[] = []; + + doc.on("data", (chunk) => chunks.push(chunk)); + + // En-tête avec logo si disponible + if (config.logoUrl) { + try { + const response = await fetch(config.logoUrl); + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + doc.image(buffer, 50, 45, { width: 100 }); + } catch (error) { + console.error("Erreur lors du chargement du logo:", error); + } + } + + // Titre + doc + .fontSize(24) + .font("Helvetica-Bold") + .text("ATTESTATION DE FORMATION", 0, 150, { align: "center" }); + + doc.moveDown(2); + + // Texte de l'attestation avec remplacement des variables + let texte = config.texteAttestation || "Nous attestons que {{nomComplet}} a suivi avec assiduité la formation \"{{nomFormation}}\" organisée du {{dateDebut}} au {{dateFin}}."; + + const dateDebut = format(donneesFictives.dates[0].date, "d MMMM yyyy", { locale: fr }); + const dateFin = format(donneesFictives.dates[donneesFictives.dates.length - 1].date, "d MMMM yyyy", { locale: fr }); + + texte = texte + .replace(/\{\{nomComplet\}\}/g, `${donneesFictives.apprenant.prenom} ${donneesFictives.apprenant.nom}`) + .replace(/\{\{nomFormation\}\}/g, donneesFictives.formation.nom) + .replace(/\{\{dateDebut\}\}/g, dateDebut) + .replace(/\{\{dateFin\}\}/g, dateFin) + .replace(/\{\{lieu\}\}/g, donneesFictives.sequence.lieu) + .replace(/\{\{nbJours\}\}/g, donneesFictives.formation.nbJours.toString()); + + doc + .fontSize(12) + .font("Helvetica") + .text(texte, { align: "justify", lineGap: 5 }); + + doc.moveDown(2); + + // Dates de formation + doc.fontSize(10).font("Helvetica-Bold").text("Dates de formation :"); + doc.font("Helvetica"); + donneesFictives.dates.forEach((d) => { + doc.text(`• ${format(d.date, "EEEE d MMMM yyyy", { locale: fr })}`, { indent: 20 }); + }); + + doc.moveDown(2); + + // Signature + doc.fontSize(10).font("Helvetica").text(`Fait à ${donneesFictives.sequence.lieu}, le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`); + + doc.moveDown(1); + + if (config.signatureUrl) { + try { + const response = await fetch(config.signatureUrl); + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + doc.image(buffer, doc.x, doc.y, { width: 150 }); + doc.moveDown(3); + } catch (error) { + console.error("Erreur lors du chargement de la signature:", error); + } + } + + if (config.nomSignataire) { + doc.font("Helvetica-Bold").text(config.nomSignataire); + } + if (config.fonctionSignataire) { + doc.font("Helvetica").text(config.fonctionSignataire); + } + + // Finaliser le PDF + doc.end(); + + await new Promise((resolve) => doc.on("end", resolve)); + + const pdfBuffer = Buffer.concat(chunks); + + // Uploader sur S3 + const randomSuffix = crypto.randomBytes(8).toString("hex"); + const s3Key = `attestations/preview/preview-${randomSuffix}.pdf`; + const { url: pdfUrl } = await storagePut(s3Key, pdfBuffer, "application/pdf"); + + return { pdfUrl }; +} diff --git a/deployment-package/source_server/attestationsDb.ts b/deployment-package/source_server/attestationsDb.ts new file mode 100644 index 0000000..3bf46e8 --- /dev/null +++ b/deployment-package/source_server/attestationsDb.ts @@ -0,0 +1,93 @@ +import { desc, eq } from "drizzle-orm"; +import { getDb } from "./db"; +import { historiqueAttestations, sequences, apprenants, formations } from "../drizzle/schema"; + +/** + * Enregistrer un envoi d'attestation dans l'historique + */ +export async function enregistrerEnvoiAttestation(data: { + sequenceId: number; + apprenantId: number; + statut: "envoye" | "erreur"; + messageErreur?: string; + urlAttestation?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.insert(historiqueAttestations).values({ + sequenceId: data.sequenceId, + apprenantId: data.apprenantId, + statut: data.statut, + messageErreur: data.messageErreur || null, + urlAttestation: data.urlAttestation || null, + }); +} + +/** + * Récupérer l'historique des envois d'attestations + * Avec les informations de la séquence, formation et apprenant + */ +export async function getHistoriqueAttestations() { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + id: historiqueAttestations.id, + dateEnvoi: historiqueAttestations.dateEnvoi, + statut: historiqueAttestations.statut, + messageErreur: historiqueAttestations.messageErreur, + urlAttestation: historiqueAttestations.urlAttestation, + sequence: { + id: sequences.id, + nom: sequences.nom, + }, + formation: { + id: formations.id, + nom: formations.nom, + }, + apprenant: { + id: apprenants.id, + nom: apprenants.nom, + prenom: apprenants.prenom, + email: apprenants.email, + }, + }) + .from(historiqueAttestations) + .innerJoin(sequences, eq(historiqueAttestations.sequenceId, sequences.id)) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id)) + .orderBy(desc(historiqueAttestations.dateEnvoi)); + + return results; +} + +/** + * Récupérer l'historique des attestations pour une séquence spécifique + */ +export async function getHistoriqueAttestationsBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + id: historiqueAttestations.id, + dateEnvoi: historiqueAttestations.dateEnvoi, + statut: historiqueAttestations.statut, + messageErreur: historiqueAttestations.messageErreur, + urlAttestation: historiqueAttestations.urlAttestation, + apprenant: { + id: apprenants.id, + nom: apprenants.nom, + prenom: apprenants.prenom, + email: apprenants.email, + }, + }) + .from(historiqueAttestations) + .innerJoin(apprenants, eq(historiqueAttestations.apprenantId, apprenants.id)) + .where(eq(historiqueAttestations.sequenceId, sequenceId)) + .orderBy(desc(historiqueAttestations.dateEnvoi)); + + return results; +} diff --git a/deployment-package/source_server/dateUtils.ts b/deployment-package/source_server/dateUtils.ts new file mode 100644 index 0000000..6f1c294 --- /dev/null +++ b/deployment-package/source_server/dateUtils.ts @@ -0,0 +1,60 @@ +/** + * Utilitaires pour la gestion des dates + * Gère correctement les conversions entre heure locale et UTC + */ + +/** + * Convertit une chaîne datetime-local (format: "2025-01-15T14:30") en Date + * en construisant la date avec une chaîne ISO qui force l'heure locale + * Préserve l'heure locale sans conversion UTC + */ +export function parseLocalDateTime(dateTimeString: string): Date { + if (!dateTimeString) { + throw new Error('Date string is required'); + } + + // Le format datetime-local est "YYYY-MM-DDTHH:mm" + const match = dateTimeString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); + + if (!match) { + throw new Error(`Invalid date format: ${dateTimeString}. Expected YYYY-MM-DDTHH:mm`); + } + + const [, year, month, day, hours, minutes] = match; + + // Créer un objet Date en utilisant le constructeur avec composants + // MySQL stocke en UTC, donc on doit compenser le décalage horaire + const date = new Date( + parseInt(year), + parseInt(month) - 1, + parseInt(day), + parseInt(hours), + parseInt(minutes), + 0 + ); + + // Compenser le décalage UTC en ajoutant le décalage du fuseau horaire + // Cela garantit que l'heure stockée en UTC correspond à l'heure locale saisie + const offset = date.getTimezoneOffset(); // en minutes (négatif pour Europe/Paris) + date.setMinutes(date.getMinutes() - offset); + + if (isNaN(date.getTime())) { + throw new Error(`Invalid date string: ${dateTimeString}`); + } + + return date; +} + +/** + * Formate une Date en chaîne datetime-local pour les inputs HTML + * Format: "YYYY-MM-DDTHH:mm" + */ +export function formatToLocalDateTime(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}`; +} diff --git a/deployment-package/source_server/db.ts b/deployment-package/source_server/db.ts new file mode 100644 index 0000000..dc0b2b3 --- /dev/null +++ b/deployment-package/source_server/db.ts @@ -0,0 +1,1272 @@ +import { eq, and, ne, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/mysql2"; +import { + InsertUser, + users, + formations, + apprenants, + sequences, + datesFormation, + inscriptions, + passwordResetTokens, + InsertPasswordResetToken, + emailTemplates, + emailConfig, + InsertEmailConfig, + InsertSequence, + InsertDateFormation, + InsertInscription, + InsertFormation, + InsertApprenant, + Sequence, + DateFormation, + Apprenant, + Formation, + EmailTemplate, + InsertEmailTemplate, + formateurs, + InsertFormateur, + Formateur, + rappels, + InsertRappel, + Rappel, + rappelDates +} from "../drizzle/schema"; +import { ENV } from './_core/env'; + +let _db: ReturnType | null = null; + +// Lazily create the drizzle instance so local tooling can run without a DB. +export async function getDb() { + if (!_db && process.env.DATABASE_URL) { + try { + _db = drizzle(process.env.DATABASE_URL); + } catch (error) { + console.warn("[Database] Failed to connect:", error); + _db = null; + } + } + return _db; +} + +export async function upsertUser(user: InsertUser): Promise { + if (!user.openId) { + throw new Error("User openId is required for upsert"); + } + + const db = await getDb(); + if (!db) { + console.warn("[Database] Cannot upsert user: database not available"); + return; + } + + try { + const values: InsertUser = { + openId: user.openId, + }; + const updateSet: Record = {}; + + const textFields = ["name", "email", "loginMethod"] as const; + type TextField = (typeof textFields)[number]; + + const assignNullable = (field: TextField) => { + const value = user[field]; + if (value === undefined) return; + const normalized = value ?? null; + values[field] = normalized; + updateSet[field] = normalized; + }; + + textFields.forEach(assignNullable); + + if (user.lastSignedIn !== undefined) { + values.lastSignedIn = user.lastSignedIn; + updateSet.lastSignedIn = user.lastSignedIn; + } + if (user.role !== undefined) { + values.role = user.role; + updateSet.role = user.role; + } else if (user.openId === ENV.ownerOpenId) { + values.role = 'admin'; + updateSet.role = 'admin'; + } + + if (!values.lastSignedIn) { + values.lastSignedIn = new Date(); + } + + if (Object.keys(updateSet).length === 0) { + updateSet.lastSignedIn = new Date(); + } + + await db.insert(users).values(values).onDuplicateKeyUpdate({ + set: updateSet, + }); + } catch (error) { + console.error("[Database] Failed to upsert user:", error); + throw error; + } +} + +export async function getUserByOpenId(openId: string) { + const db = await getDb(); + if (!db) { + console.warn("[Database] Cannot get user: database not available"); + return undefined; + } + + const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); + + return result.length > 0 ? result[0] : undefined; +} + +// ==================== FORMATIONS ==================== + +export async function createFormation(data: InsertFormation) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(formations).values(data); + return result; +} + +export async function getFormations() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(formations); +} + +export async function getFormationsByFormateur(formateurId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer les IDs de formations uniques pour lesquelles le formateur a des séquences + const seqs = await db.select({ formationId: sequences.formationId }) + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + const formationIds = Array.from(new Set(seqs.map(s => s.formationId))); + + if (formationIds.length === 0) return []; + + // Récupérer les formations correspondantes + const result = await db.select().from(formations).where( + sql`${formations.id} IN (${sql.join(formationIds.map(id => sql`${id}`), sql`, `)})` + ); + + return result; +} + +export async function getFormationById(id: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(formations).where(eq(formations.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getFormationByLien(lien: string) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(formations).where(eq(formations.lienUnique, lien)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateFormation(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(formations).set(data).where(eq(formations.id, id)); +} + +export async function deleteFormation(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(formations).where(eq(formations.id, id)); +} + +// ==================== SÉQUENCES ==================== + +export async function createSequence(data: InsertSequence) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(sequences).values(data); + return result; +} + +export async function getSequences() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(sequences); +} + +export async function getSequencesWithFormation() { + const db = await getDb(); + if (!db) return []; + + const seqs = await db.select().from(sequences); + + // Récupérer les informations de formation et de formateur pour chaque séquence + return await Promise.all( + seqs.map(async (seq) => { + const formation = await db.select().from(formations).where(eq(formations.id, seq.formationId)).limit(1); + + let formateur = null; + if (seq.formateurId) { + const formateurResult = await db.select().from(formateurs).where(eq(formateurs.id, seq.formateurId)).limit(1); + if (formateurResult.length > 0) { + formateur = { id: formateurResult[0].id, nom: formateurResult[0].nom }; + } + } + + return { + ...seq, + formation: formation.length > 0 ? { id: formation[0].id, nom: formation[0].nom } : null, + formateur, + }; + }) + ); +} + +export async function getSequencesByFormateur(formateurId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer uniquement les séquences de ce formateur + const seqs = await db.select().from(sequences).where(eq(sequences.formateurId, formateurId)); + + // Récupérer les informations de formation et de formateur pour chaque séquence + return await Promise.all( + seqs.map(async (seq) => { + const formation = await db.select().from(formations).where(eq(formations.id, seq.formationId)).limit(1); + + let formateur = null; + if (seq.formateurId) { + const formateurResult = await db.select().from(formateurs).where(eq(formateurs.id, seq.formateurId)).limit(1); + if (formateurResult.length > 0) { + formateur = { id: formateurResult[0].id, nom: formateurResult[0].nom }; + } + } + + return { + ...seq, + formation: formation.length > 0 ? { id: formation[0].id, nom: formation[0].nom } : null, + formateur, + }; + }) + ); +} + +export async function getSequenceById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(sequences).where(eq(sequences.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getSequencesByFormation(formationId: number) { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(sequences).where(eq(sequences.formationId, formationId)); +} + +export async function updateSequence(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(sequences).set(data).where(eq(sequences.id, id)); +} + +export async function deleteSequence(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(sequences).where(eq(sequences.id, id)); +} + +// ==================== DATES DE FORMATION ==================== + +export async function createDateFormation(data: InsertDateFormation) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(datesFormation).values(data); + return result; +} + +export async function getDatesBySequence(sequenceId: number): Promise { + const db = await getDb(); + if (!db) return []; + + const results = await db.select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequenceId)) + .orderBy(datesFormation.ordre); + + return results; +} + +export async function deleteDatesBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)); +} + +// ==================== APPRENANTS ==================== + +export async function createApprenant(data: InsertApprenant) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(apprenants).values(data); + return result; +} + +export async function getApprenants() { + const db = await getDb(); + if (!db) return []; + + const apprenantsData = await db.select().from(apprenants); + + // Pour chaque apprenant, récupérer ses inscriptions + const apprenantsWithInscriptions = await Promise.all( + apprenantsData.map(async (apprenant) => { + const inscriptionsData = await db + .select() + .from(inscriptions) + .where(eq(inscriptions.apprenantId, apprenant.id)); + + return { + ...apprenant, + inscriptions: inscriptionsData, + }; + }) + ); + + return apprenantsWithInscriptions; +} + +export async function getApprenantsByFormateur(formateurId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer les IDs de séquences du formateur + const seqs = await db.select({ id: sequences.id }) + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + const sequenceIds = seqs.map(s => s.id); + + if (sequenceIds.length === 0) return []; + + // Récupérer les IDs d'apprenants inscrits à ces séquences + const inscriptionsData = await db.select({ apprenantId: inscriptions.apprenantId }) + .from(inscriptions) + .where( + sql`${inscriptions.sequenceId} IN (${sql.join(sequenceIds.map(id => sql`${id}`), sql`, `)})` + ); + + const apprenantIds = Array.from(new Set(inscriptionsData.map(i => i.apprenantId))); + + if (apprenantIds.length === 0) return []; + + // Récupérer les apprenants correspondants + const apprenantsData = await db.select().from(apprenants).where( + sql`${apprenants.id} IN (${sql.join(apprenantIds.map(id => sql`${id}`), sql`, `)})` + ); + + // Pour chaque apprenant, récupérer ses inscriptions (filtrées par les séquences du formateur) + const apprenantsWithInscriptions = await Promise.all( + apprenantsData.map(async (apprenant) => { + const inscriptionsFiltered = await db + .select() + .from(inscriptions) + .where( + and( + eq(inscriptions.apprenantId, apprenant.id), + sql`${inscriptions.sequenceId} IN (${sql.join(sequenceIds.map(id => sql`${id}`), sql`, `)})` + ) + ); + + return { + ...apprenant, + inscriptions: inscriptionsFiltered, + }; + }) + ); + + return apprenantsWithInscriptions; +} + +export async function getApprenantById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(apprenants).where(eq(apprenants.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getApprenantByEmail(email: string) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(apprenants).where(eq(apprenants.email, email)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateApprenant(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(apprenants).set(data).where(eq(apprenants.id, id)); +} + +export async function deleteApprenant(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(apprenants).where(eq(apprenants.id, id)); +} + +// ==================== INSCRIPTIONS ==================== + +export async function createInscription(data: InsertInscription) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(inscriptions).values(data); + return result; +} + +export async function getAllInscriptions() { + const db = await getDb(); + if (!db) return []; + + const results = await db.select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)); + + return results; +} + +export async function getInscriptionsBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db.select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where(and( + eq(inscriptions.sequenceId, sequenceId), + ne(inscriptions.statut, 'annulee') + )); + + return results; +} + +export async function getInscriptionsByApprenant(apprenantId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db.select({ + inscription: inscriptions, + sequence: sequences, + }) + .from(inscriptions) + .leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where(and( + eq(inscriptions.apprenantId, apprenantId), + ne(inscriptions.statut, 'annulee') + )); + + return results; +} + +export async function checkExistingInscription(apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select() + .from(inscriptions) + .where(and( + eq(inscriptions.apprenantId, apprenantId), + eq(inscriptions.sequenceId, sequenceId), + ne(inscriptions.statut, 'annulee') + )) + .limit(1); + + return result.length > 0 ? result[0] : undefined; +} + +export async function countInscriptionsBySequence(sequenceId: number, statut?: string) { + const db = await getDb(); + if (!db) return 0; + + const conditions = [eq(inscriptions.sequenceId, sequenceId)]; + if (statut) { + conditions.push(eq(inscriptions.statut, statut as any)); + } + + const result = await db.select({ count: sql`count(*)` }) + .from(inscriptions) + .where(and(...conditions)); + + return result[0]?.count || 0; +} + +export async function getInscriptionById(id: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(inscriptions).where(eq(inscriptions.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateInscription(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(inscriptions).set(data).where(eq(inscriptions.id, id)); +} + +export async function deleteInscription(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(inscriptions).where(eq(inscriptions.id, id)); +} + +// ==================== GESTION DES UTILISATEURS ==================== + +export async function createUser(data: InsertUser) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Hasher le mot de passe si fourni + if (data.password) { + const bcrypt = await import('bcryptjs'); + data.password = await bcrypt.hash(data.password, 10); + } + + const result = await db.insert(users).values(data); + return result; +} + +export async function getAllUsers() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(users); +} + +export async function getUserById(id: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(users).where(eq(users.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateUser(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Hasher le mot de passe si fourni + if (data.password) { + const bcrypt = await import('bcryptjs'); + data.password = await bcrypt.hash(data.password, 10); + } + + await db.update(users).set(data).where(eq(users.id, id)); +} + +export async function toggleUserStatus(id: number, isActive: boolean) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(users).set({ isActive }).where(eq(users.id, id)); +} + +export async function deleteUser(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(users).where(eq(users.id, id)); +} + + +// ==================== GESTION DES TOKENS DE RÉINITIALISATION ==================== + +export async function createPasswordResetToken(userId: number, token: string, expiresAt: Date) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(passwordResetTokens).values({ + userId, + token, + expiresAt, + used: false, + }); + return result; +} + +export async function getPasswordResetToken(token: string) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select() + .from(passwordResetTokens) + .where(eq(passwordResetTokens.token, token)) + .limit(1); + + return result.length > 0 ? result[0] : undefined; +} + +export async function markTokenAsUsed(tokenId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(passwordResetTokens) + .set({ used: true }) + .where(eq(passwordResetTokens.id, tokenId)); +} + +export async function deleteExpiredTokens() { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const now = new Date(); + await db.delete(passwordResetTokens) + .where(sql`${passwordResetTokens.expiresAt} < ${now}`); +} + +// ============================================ +// Email Templates Management +// ============================================ + +/** + * Récupère tous les templates d'emails + */ +export async function getAllEmailTemplates() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(emailTemplates); +} + +/** + * Récupère un template par son type + */ +export async function getEmailTemplateByType(type: string) { + const db = await getDb(); + if (!db) return null; + + const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1); + return results.length > 0 ? results[0] : null; +} + +/** + * Crée ou met à jour un template d'email + */ +export async function upsertEmailTemplate(template: InsertEmailTemplate) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const existing = await getEmailTemplateByType(template.type); + + if (existing) { + // Mise à jour + await db.update(emailTemplates) + .set({ + name: template.name, + logoUrl: template.logoUrl, + primaryColor: template.primaryColor, + headerBgColor: template.headerBgColor, + headerTextColor: template.headerTextColor, + headerTitle: template.headerTitle, + bodyContent: template.bodyContent, + footerText: template.footerText, + active: template.active, + updatedAt: new Date(), + }) + .where(eq(emailTemplates.type, template.type)); + + return await getEmailTemplateByType(template.type); + } else { + // Création + await db.insert(emailTemplates).values(template); + return await getEmailTemplateByType(template.type); + } +} + +/** + * Supprime un template d'email + */ +export async function deleteEmailTemplate(type: string) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(emailTemplates).where(eq(emailTemplates.type, type)); +} + +/** + * Initialise les templates par défaut si la table est vide + */ +export async function initializeDefaultEmailTemplates() { + const db = await getDb(); + if (!db) return; + + const existing = await getAllEmailTemplates(); + if (existing.length > 0) return; // Déjà initialisé + + const defaultTemplates: InsertEmailTemplate[] = [ + { + type: "inscription", + name: "Confirmation d'inscription", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "teaser", + name: "Email teaser", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappel", + name: "Rappel J-7", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence bientôt !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence dans une semaine.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappelJ1", + name: "Rappel J-1", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence demain !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence demain.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

Merci de vous présenter à l'heure indiquée.

+

N'oubliez pas d'apporter le matériel nécessaire.

+ +

À demain !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappel3", + name: "Rappel 3", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence bientôt !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence dans une semaine.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappel4", + name: "Rappel 4", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence bientôt !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence dans une semaine.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappel5", + name: "Rappel 5", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence bientôt !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence dans une semaine.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "rappel6", + name: "Rappel 6", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + bodyContent: `

Rappel : Votre formation commence bientôt !

+

Bonjour {{prenomApprenant}} {{nomApprenant}},

+ +

Nous vous rappelons que votre formation {{nomFormation}} commence dans une semaine.

+ +
+

Séquence : {{nomSequence}}

+

Dates :

+ {{datesHTML}} +

Lieu : {{lieu}}

+

Formateur : {{formateur}}

+
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

`, + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + { + type: "reset_password", + name: "Réinitialisation de mot de passe", + logoUrl: null, + primaryColor: "#2563eb", + headerBgColor: "#2563eb", + headerTextColor: "#ffffff", + headerTitle: "Formation Manager Itinova", + footerText: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.", + active: true, + }, + ]; + + for (const template of defaultTemplates) { + await db.insert(emailTemplates).values(template); + } + + console.log("[DB] Templates d'emails par défaut initialisés"); +} + + +// ==================== Email Config ==================== + +/** + * Récupère la configuration email active + */ +export async function getActiveEmailConfig() { + const db = await getDb(); + if (!db) return undefined; + + const result = await db + .select() + .from(emailConfig) + .where(eq(emailConfig.active, true)) + .limit(1); + + return result.length > 0 ? result[0] : undefined; +} + +/** + * Crée ou met à jour la configuration email + */ +export async function upsertEmailConfig(data: InsertEmailConfig) { + const db = await getDb(); + if (!db) return; + + // Vérifier s'il existe déjà une configuration active + const existing = await db + .select() + .from(emailConfig) + .where(eq(emailConfig.active, true)) + .limit(1); + + if (existing.length > 0) { + // Mettre à jour la configuration existante + await db + .update(emailConfig) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(emailConfig.id, existing[0].id)); + } else { + // Créer une nouvelle configuration + await db.insert(emailConfig).values({ + ...data, + active: true, + }); + } +} + +/** + * Met à jour la configuration email + */ +export async function updateEmailConfig(id: number, data: Partial) { + const db = await getDb(); + if (!db) return; + + await db + .update(emailConfig) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(emailConfig.id, id)); +} + + +// ==================== FORMATEURS ==================== + +export async function createFormateur(data: InsertFormateur) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.insert(formateurs).values(data); + return result; +} + +export async function getFormateurs() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(formateurs); +} + +export async function getFormateurById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(formateurs).where(eq(formateurs.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateFormateur(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(formateurs).set(data).where(eq(formateurs.id, id)); +} + +export async function deleteFormateur(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(formateurs).where(eq(formateurs.id, id)); +} + + +// ==================== RAPPELS ==================== + +export async function createRappel(data: InsertRappel & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Construire un objet d'insertion explicite + const insertData: Partial = { + nom: data.nom, + templateType: data.templateType, + timing: data.timing, + joursAvant: data.joursAvant, + heureEnvoi: data.heureEnvoi, + actif: data.actif, + }; + + // Ajouter les champs de fichier si présents + if (data.fichier) { + insertData.nomFichier = data.fichier.nomFichier; + insertData.urlFichier = data.fichier.urlFichier; + insertData.s3Key = data.fichier.s3Key; + insertData.typeFichier = data.fichier.typeFichier; + insertData.tailleFichier = data.fichier.tailleFichier; + } + + const result = await db.insert(rappels).values(insertData as InsertRappel); + return result; +} + +export async function getRappels() { + const db = await getDb(); + if (!db) return []; + + const allRappels = await db.select().from(rappels).orderBy(rappels.joursAvant); + + // Pour chaque rappel, récupérer les dates de formation associées avec leurs infos + const rappelsWithDates = await Promise.all( + allRappels.map(async (rappel) => { + const dateFormationIds = await getRappelDates(rappel.id); + + // Récupérer les détails des dates de formation + const datesDetails = await Promise.all( + dateFormationIds.map(async (dateId) => { + const dateFormationResult = await db.select().from(datesFormation).where(eq(datesFormation.id, dateId)).limit(1); + if (dateFormationResult.length === 0) return null; + + const dateFormation = dateFormationResult[0]; + const sequenceResult = await db.select().from(sequences).where(eq(sequences.id, dateFormation.sequenceId)).limit(1); + if (sequenceResult.length === 0) return null; + + const sequence = sequenceResult[0]; + const formationResult = await db.select().from(formations).where(eq(formations.id, sequence.formationId)).limit(1); + if (formationResult.length === 0) return null; + + const formation = formationResult[0]; + + return { + dateFormationId: dateFormation.id, + dateDebut: dateFormation.dateDebut, + dateFin: dateFormation.dateFin, + ordre: dateFormation.ordre, + sequenceId: sequence.id, + sequenceNom: sequence.nom, + formationId: formation.id, + formationNom: formation.nom, + }; + }) + ); + + return { + ...rappel, + dateFormationIds, + datesDetails: datesDetails.filter(d => d !== null), + }; + }) + ); + + return rappelsWithDates; +} + +export async function getRappelById(id: number): Promise { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(rappels).where(eq(rappels.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getActiveRappels(): Promise { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(rappels).where(eq(rappels.actif, true)); +} + +export async function updateRappel(id: number, data: Partial & { fichier?: { nomFichier: string; urlFichier: string; s3Key: string; typeFichier: string; tailleFichier: number; } | null }) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const updateData: any = { ...data }; + + // Gérer les champs de fichier + if ('fichier' in data) { + delete updateData.fichier; + if (data.fichier) { + updateData.nomFichier = data.fichier.nomFichier; + updateData.urlFichier = data.fichier.urlFichier; + updateData.s3Key = data.fichier.s3Key; + updateData.typeFichier = data.fichier.typeFichier; + updateData.tailleFichier = data.fichier.tailleFichier; + } else { + // Supprimer le fichier + updateData.nomFichier = null; + updateData.urlFichier = null; + updateData.s3Key = null; + updateData.typeFichier = null; + updateData.tailleFichier = null; + } + } + + await db.update(rappels).set({ + ...updateData, + updatedAt: new Date(), + }).where(eq(rappels.id, id)); +} + +export async function deleteRappel(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Supprimer d'abord les associations + await db.delete(rappelDates).where(eq(rappelDates.rappelId, id)); + // Puis le rappel + await db.delete(rappels).where(eq(rappels.id, id)); +} + +// ==================== RAPPEL DATES ==================== + +export async function createRappelDates(rappelId: number, dateFormationIds: number[]) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + if (dateFormationIds.length === 0) return; + + const values = dateFormationIds.map(dateFormationId => ({ + rappelId, + dateFormationId, + })); + + await db.insert(rappelDates).values(values); +} + +export async function deleteRappelDates(rappelId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(rappelDates).where(eq(rappelDates.rappelId, rappelId)); +} + +export async function getRappelDates(rappelId: number): Promise { + const db = await getDb(); + if (!db) return []; + + const results = await db.select().from(rappelDates).where(eq(rappelDates.rappelId, rappelId)); + return results.map(r => r.dateFormationId); +} + +export async function updateRappelExecution(id: number) { + const db = await getDb(); + if (!db) return; + + await db.update(rappels).set({ + derniereExecution: new Date(), + }).where(eq(rappels.id, id)); +} + +export async function getRappelsByDateFormation(dateFormationId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer les IDs des rappels associés à cette date + const rappelDatesResults = await db.select().from(rappelDates).where(eq(rappelDates.dateFormationId, dateFormationId)); + const rappelIds = rappelDatesResults.map(rd => rd.rappelId); + + if (rappelIds.length === 0) { + // Retourner tous les rappels actifs qui n'ont pas de dates spécifiques (rappels globaux) + const allRappels = await db.select().from(rappels).where(eq(rappels.actif, true)); + const globalRappels = []; + + for (const rappel of allRappels) { + const dates = await getRappelDates(rappel.id); + if (dates.length === 0) { + globalRappels.push(rappel); + } + } + + return globalRappels; + } + + // Récupérer les détails des rappels + const rappelsResults = await Promise.all( + rappelIds.map(async (id) => { + const rappelResult = await db.select().from(rappels).where(eq(rappels.id, id)).limit(1); + return rappelResult.length > 0 ? rappelResult[0] : null; + }) + ); + + // Filtrer les null et retourner + return rappelsResults.filter((r): r is Rappel => r !== null); +} + + +// ==================== UTILISATEURS ADMIN ==================== + +/** + * Récupère tous les utilisateurs avec le rôle admin + */ +export async function getAdminUsers() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(users).where(eq(users.role, 'admin')); +} diff --git a/deployment-package/source_server/emailPreview.ts b/deployment-package/source_server/emailPreview.ts new file mode 100644 index 0000000..9628c4d --- /dev/null +++ b/deployment-package/source_server/emailPreview.ts @@ -0,0 +1,177 @@ +/** + * Génération d'aperçus d'emails avec données réelles + */ + +import * as db from "./db"; +import { generateEmailFromTemplate } from "./emailTemplateGenerator"; + +export interface EmailPreviewParams { + sequenceId: number; + type: 'teaser' | 'rappel' | 'rappel_j1'; + apprenantId?: number; // Si non fourni, prendre le premier apprenant inscrit +} + +export async function generateEmailPreview(params: EmailPreviewParams): Promise<{ + html: string; + subject: string; + recipient: { + email: string; + nom: string; + prenom: string; + }; +}> { + // Récupérer la séquence + const sequence = await db.getSequenceById(params.sequenceId); + if (!sequence) { + throw new Error('Séquence non trouvée'); + } + + // Récupérer la formation + const formation = await db.getFormationById(sequence.formationId); + if (!formation) { + throw new Error('Formation non trouvée'); + } + + // Récupérer les dates + const dates = await db.getDatesBySequence(sequence.id); + if (dates.length === 0) { + throw new Error('Aucune date trouvée pour cette séquence'); + } + + // Récupérer le formateur si disponible + let formateurNom: string | undefined; + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + formateurNom = formateur?.nom; + } + + // Récupérer un apprenant inscrit + const inscriptions = await db.getInscriptionsBySequence(params.sequenceId); + const confirmedInscriptions = inscriptions.filter(i => i.inscription.statut === 'confirmee' && i.apprenant); + + if (confirmedInscriptions.length === 0) { + throw new Error('Aucun apprenant confirmé trouvé pour cette séquence'); + } + + // Utiliser l'apprenant spécifié ou le premier de la liste + let selectedInscription = confirmedInscriptions[0]; + if (params.apprenantId) { + const found = confirmedInscriptions.find(i => i.apprenant!.id === params.apprenantId); + if (found) { + selectedInscription = found; + } + } + + const apprenant = selectedInscription.apprenant!; + + // Préparer les données pour l'email + const fonctionLabel = apprenant.fonction === 'directeur' ? 'Directeur' : + apprenant.fonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${apprenant.prenom} ${apprenant.nom}` : apprenant.prenom; + + const datesHTML = dates.map(date => ` +
+ Date ${date.ordre} : ${new Date(date.dateDebut).toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
+ `).join(''); + + // Générer le contenu selon le type + let content: string; + let subject: string; + + if (params.type === 'teaser') { + content = ` +

Votre formation approche !

+

Bonjour ${salutation},

+ +

Nous sommes ravis de vous accueillir prochainement pour la formation ${formation.nom}.

+ +
+

Séquence : ${sequence.nom}

+

Dates :

+ ${datesHTML} + ${sequence.lieu ? `

Lieu : ${sequence.lieu}

` : ''} + ${formateurNom ? `

Formateur : ${formateurNom}

` : ''} +
+ +

Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.

+ +

À très bientôt !

+ `; + subject = `Votre formation ${formation.nom} approche !`; + } else if (params.type === 'rappel') { + // Rappel J-7 + content = ` +

Rappel : Votre formation commence bientôt !

+

Bonjour ${salutation},

+ +

Nous vous rappelons que votre formation ${formation.nom} commence dans une semaine.

+ +
+

Séquence : ${sequence.nom}

+

Dates :

+ ${datesHTML} + ${sequence.lieu ? `

Lieu : ${sequence.lieu}

` : ''} + ${formateurNom ? `

Formateur : ${formateurNom}

` : ''} +
+ +

N'oubliez pas d'apporter le matériel nécessaire et de vous présenter à l'heure indiquée.

+ +

À très bientôt !

+ `; + subject = `Rappel J-7 : Formation ${formation.nom}`; + } else { + // Rappel J-1 + content = ` +

Rappel : Votre formation commence demain !

+

Bonjour ${salutation},

+ +

Nous vous rappelons que votre formation ${formation.nom} commence demain.

+ +
+

Séquence : ${sequence.nom}

+

Dates :

+ ${datesHTML} + ${sequence.lieu ? `

Lieu : ${sequence.lieu}

` : ''} + ${formateurNom ? `

Formateur : ${formateurNom}

` : ''} +
+ +

Merci de vous présenter à l'heure indiquée.

+

N'oubliez pas d'apporter le matériel nécessaire.

+ +

À demain !

+ `; + subject = `Rappel J-1 : Formation ${formation.nom} - C'est demain !`; + } + + // Préparer les variables + const variables = { + nomApprenant: apprenant.nom, + prenomApprenant: apprenant.prenom, + nomFormation: formation.nom, + nomSequence: sequence.nom, + dateDebut: new Date(dates[0].dateDebut).toLocaleDateString('fr-FR'), + dateFin: new Date(dates[dates.length - 1].dateFin).toLocaleDateString('fr-FR'), + datesHTML: datesHTML, // Ajouter le HTML des dates + lieu: sequence.lieu || '', + formateur: formateurNom || '', + }; + + // Générer le HTML final avec le template + const html = await generateEmailFromTemplate(params.type === 'teaser' ? 'teaser' : 'rappel', content, variables); + + return { + html, + subject, + recipient: { + email: apprenant.email, + nom: apprenant.nom, + prenom: apprenant.prenom, + }, + }; +} diff --git a/deployment-package/source_server/emailService.ts b/deployment-package/source_server/emailService.ts new file mode 100644 index 0000000..68f2790 --- /dev/null +++ b/deployment-package/source_server/emailService.ts @@ -0,0 +1,1023 @@ +/** + * Service d'envoi d'emails pour les formations + * Utilise le système de notification Manus pour envoyer des emails + */ + +import { generateFormationICS } from "./icsGenerator"; +import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_core/emailSender"; +import { generateEmailFromTemplate } from "./emailTemplateGenerator"; +import { EmailVariables } from "./emailTemplateUtils"; + +interface EmailParams { + to: string; + subject: string; + html: string; + attachments?: Array<{ + filename: string; + content: string; + contentType: string; + }>; +} + +/** + * Envoie un email via le service d'envoi configuré + * Utilise Resend si RESEND_API_KEY est configuré, sinon simule l'envoi + */ +async function sendEmail(params: EmailParams): Promise { + return await sendEmailViaService(params); +} + +/** + * Template HTML de base pour les emails (avec template personnalisé) + */ +async function getEmailTemplate( + content: string, + templateType: string = 'inscription', + variables?: EmailVariables +): Promise { + return await generateEmailFromTemplate(templateType, content, variables); +} + +/** + * Envoie un email de confirmation d'inscription avec invitations Outlook pour toutes les dates + */ +export async function sendInscriptionConfirmation(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu: string; + statut: 'confirmee' | 'liste_attente'; +}): Promise { + const isConfirmed = params.statut === 'confirmee'; + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + + // Générer la liste des dates + const datesHTML = params.dates.map(date => ` +
+ Date ${date.ordre} :
+ Du ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}
+ au ${date.dateFin.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+ `).join(''); + + const content = ` +

Confirmation d'inscription

+

Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},

+ + ${isConfirmed + ? '

Votre inscription a été confirmée !

' + : '

Vous avez été ajouté à la liste d\'attente.

' + } + +
+

Détails de la formation

+

Formation : ${params.formationNom}

+

Séquence : ${params.sequenceNom}

+

Lieu : ${params.lieu}

+

Dates de formation (${params.dates.length} séance${params.dates.length > 1 ? 's' : ''}) :

+ ${datesHTML} +
+ + ${isConfirmed + ? `

Des invitations Outlook sont jointes à cet email pour chaque date de formation. Merci de les ajouter à votre calendrier pour bloquer votre agenda.

+

Important : Les inscriptions seront bloquées 15 jours avant le début de la séquence. Après cette date, aucune modification ne sera possible.

` + : '

Nous vous contacterons dès qu\'une place se libère.

' + } + +

À bientôt pour cette formation !

+ `; + + // Générer une invitation ICS pour chaque date + const attachments = isConfirmed ? params.dates.map((date, index) => ({ + filename: `invitation_date_${date.ordre}.ics`, + content: generateFormationICS({ + formationNom: params.formationNom, + sessionNom: `${params.sequenceNom} - Date ${date.ordre}`, + dateDebut: date.dateDebut, + dateFin: date.dateFin, + lieu: params.lieu, + apprenantNom: params.apprenantNom, + apprenantPrenom: params.apprenantPrenom, + apprenantEmail: params.apprenantEmail, + }), + contentType: 'text/calendar', + })) : undefined; + + // Préparer les variables pour le remplacement (avec alias pour compatibilité) + const variables: EmailVariables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + dateDebut: params.dates[0]?.dateDebut.toLocaleDateString('fr-FR') || '', + dateFin: params.dates[params.dates.length - 1]?.dateFin.toLocaleDateString('fr-FR') || '', + lieu: params.lieu, + datesHTML: datesHTML, // HTML formaté de toutes les dates + // Alias pour compatibilité avec les templates utilisant les noms courts + nom: params.apprenantNom, + prenom: params.apprenantPrenom, + formation: params.formationNom, + sequence: params.sequenceNom, + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: isConfirmed + ? `Confirmation d'inscription - ${params.formationNom}` + : `Liste d'attente - ${params.formationNom}`, + html: await getEmailTemplate(content, 'inscription', variables), + attachments, + }); +} + +/** + * Envoie un email teaser pour une séquence + */ +export async function sendTeaserEmail(params: { + apprenantEmail: string; + apprenantPrenom: string; + apprenantNom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu?: string; + formateur?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const datesHTML = params.dates.map(date => ` +
+ Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
+ `).join(''); + + const content = ` +

Votre formation approche !

+

Bonjour ${salutation},

+ +

Nous sommes ravis de vous accueillir prochainement pour la formation ${params.formationNom}.

+ +
+

Séquence : ${params.sequenceNom}

+

Dates :

+ ${datesHTML} + ${params.lieu ? `

Lieu : ${params.lieu}

` : ''} + ${params.formateur ? `

Formateur : ${params.formateur}

` : ''} +
+ +

Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.

+ +

À très bientôt !

+ `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + dateDebut: params.dates[0]?.dateDebut.toLocaleDateString('fr-FR') || '', + dateFin: params.dates[params.dates.length - 1]?.dateFin.toLocaleDateString('fr-FR') || '', + datesHTML: datesHTML, // Ajouter le HTML des dates + lieu: params.lieu || '', + formateur: params.formateur || '', + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: `Votre formation ${params.formationNom} approche !`, + html: await getEmailTemplate(content, 'teaser', variables), + }); +} + +/** + * Envoie un email de rappel J-7 + */ +export async function sendRappelJ7Email(params: { + apprenantEmail: string; + apprenantPrenom: string; + apprenantNom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu: string; + formateur?: string; + attachmentUrl?: string; + attachmentFilename?: string; + attachmentMimeType?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const datesHTML = params.dates.map(date => ` +
+ Date ${date.ordre} :
+ ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} +
+ `).join(''); + + const content = ` +

Rappel : Votre formation dans 7 jours

+

Bonjour ${salutation},

+ +

Nous vous rappelons que votre formation ${params.formationNom} commence dans 7 jours.

+ +
+

Informations pratiques

+

Séquence : ${params.sequenceNom}

+

Lieu : ${params.lieu}

+ ${params.formateur ? `

Formateur : ${params.formateur}

` : ''} +

Dates :

+ ${datesHTML} +
+ +

Merci de vous présenter à l'heure indiquée.

+ +

Si vous avez des questions, n'hésitez pas à contacter le service RH.

+ +

À très bientôt !

+ `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + dateDebut: params.dates[0]?.dateDebut.toLocaleDateString('fr-FR') || '', + dateFin: params.dates[params.dates.length - 1]?.dateFin.toLocaleDateString('fr-FR') || '', + datesHTML: datesHTML, // Ajouter le HTML des dates + lieu: params.lieu, + formateur: params.formateur || '', + }; + + // Préparer les pièces jointes si nécessaire + const attachments: Array<{ filename: string; content: string; contentType: string }> = []; + + if (params.attachmentUrl && params.attachmentFilename && params.attachmentMimeType) { + try { + // Télécharger le fichier depuis S3 et le convertir en base64 + const response = await fetch(params.attachmentUrl); + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + + attachments.push({ + filename: params.attachmentFilename, + content: base64, + contentType: params.attachmentMimeType, + }); + } catch (error) { + console.error('[Email] Erreur lors du téléchargement de la pièce jointe:', error); + // Continuer l'envoi sans la pièce jointe + } + } + + return sendEmail({ + to: params.apprenantEmail, + subject: `Rappel J-7 : ${params.formationNom}`, + html: await getEmailTemplate(content, 'rappel', variables), + attachments: attachments.length > 0 ? attachments : undefined, + }); +} + +/** + * Envoie un email de rappel J-1 à un apprenant + */ +export async function sendRappelJ1Email(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu?: string; + formateur?: string; + attachmentUrl?: string; + attachmentFilename?: string; + attachmentMimeType?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const datesHTML = params.dates.map(date => ` +
+ Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} - ${date.dateFin.toLocaleDateString('fr-FR', { + hour: '2-digit', + minute: '2-digit' + })} +
+ `).join(''); + + const content = ` +

Rappel : Votre formation commence demain !

+

Bonjour ${salutation},

+ +

Nous vous rappelons que votre formation ${params.formationNom} commence demain.

+ +
+

Informations pratiques

+

Séquence : ${params.sequenceNom}

+ ${params.lieu ? `

Lieu : ${params.lieu}

` : ''} + ${params.formateur ? `

Formateur : ${params.formateur}

` : ''} +

Dates :

+ ${datesHTML} +
+ +

Merci de vous présenter à l'heure indiquée.

+

N'oubliez pas d'apporter le matériel nécessaire.

+ +

Si vous avez des questions de dernière minute, n'hésitez pas à contacter le service RH.

+ +

À demain !

+ `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + dateDebut: params.dates[0]?.dateDebut.toLocaleDateString('fr-FR') || '', + dateFin: params.dates[params.dates.length - 1]?.dateFin.toLocaleDateString('fr-FR') || '', + datesHTML: datesHTML, // Ajouter le HTML des dates + lieu: params.lieu || '', + formateur: params.formateur || '', + }; + + // Préparer les pièces jointes si nécessaire + const attachments: Array<{ filename: string; content: string; contentType: string }> = []; + + if (params.attachmentUrl && params.attachmentFilename && params.attachmentMimeType) { + try { + // Télécharger le fichier depuis S3 et le convertir en base64 + const response = await fetch(params.attachmentUrl); + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + + attachments.push({ + filename: params.attachmentFilename, + content: base64, + contentType: params.attachmentMimeType, + }); + } catch (error) { + console.error('[Email] Erreur lors du téléchargement de la pièce jointe:', error); + // Continuer l'envoi sans la pièce jointe + } + } + + return sendEmail({ + to: params.apprenantEmail, + subject: `Rappel J-1 : ${params.formationNom} - C'est demain !`, + html: await getEmailTemplate(content, 'rappel', variables), + attachments: attachments.length > 0 ? attachments : undefined, + }); +} + +/** + * Envoie des emails groupés à tous les inscrits d'une séquence + */ +export async function sendGroupEmail(params: { + recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>; + formationNom: string; + sequenceNom: string; + type: 'teaser' | 'rappel' | 'rappel_j1'; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu?: string; + formateur?: string; +}): Promise<{ sent: number; failed: number }> { + let sent = 0; + let failed = 0; + + for (const recipient of params.recipients) { + try { + if (params.type === 'teaser') { + await sendTeaserEmail({ + 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, + }); + } else if (params.type === 'rappel') { + await sendRappelJ7Email({ + 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, + }); + } 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++; + } catch (error) { + console.error(`Erreur envoi email à ${recipient.email}:`, error); + failed++; + } + } + + return { sent, failed }; +} + +/** + * Envoie un email de réinitialisation de mot de passe + */ +export async function sendPasswordResetEmail(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + resetLink: string; +}): Promise { + const content = ` +

Réinitialisation de mot de passe

+

Bonjour ${params.apprenantPrenom} ${params.apprenantNom},

+ +

Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.

+ +

Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :

+ + + +

Ou copiez ce lien dans votre navigateur :

+
+ ${params.resetLink} +
+ +
+

⚠️ Important :

+
    +
  • Ce lien est valide pendant 24 heures seulement
  • +
  • Il ne peut être utilisé qu'une seule fois
  • +
  • Si vous n'avez pas demandé cette réinitialisation, ignorez cet email
  • +
+
+ +

Pour toute question, contactez le service RH.

+ `; + + return sendEmail({ + to: params.apprenantEmail, + subject: 'Réinitialisation de votre mot de passe - Manager Itinova', + html: await getEmailTemplate(content, 'reset_password'), + }); +} + + +/** + * ======================================== + * SYSTÈME DE NOTIFICATIONS ENRICHI + * ======================================== + */ + +/** + * Envoie un email de remerciement post-formation à un apprenant + */ +export async function sendRemerciementPostFormation(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + formateurNom?: string; + lienQuestionnaire?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const content = ` +

Merci pour votre participation !

+

Bonjour ${salutation},

+ +

Nous tenons à vous remercier pour votre participation à la formation ${params.formationNom} (${params.sequenceNom}).

+ + ${params.formateurNom ? `

Nous espérons que les sessions animées par ${params.formateurNom} ont répondu à vos attentes.

` : ''} + +
+

Votre avis compte !

+

Afin d'améliorer continuellement nos formations, nous vous invitons à partager votre retour d'expérience.

+ ${params.lienQuestionnaire ? ` + + ` : '

Un questionnaire de satisfaction vous sera envoyé prochainement.

'} +
+ +

Nous vous souhaitons une excellente continuation dans vos fonctions et espérons vous revoir lors de prochaines formations.

+ +

Cordialement,
L'équipe Formation

+ `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + formateur: params.formateurNom || '', + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: `Merci pour votre participation - ${params.formationNom}`, + html: await getEmailTemplate(content, 'remerciement', variables), + }); +} + +/** + * Envoie une notification au formateur pour une nouvelle inscription + */ +export async function sendNotificationFormateurNouvelleInscription(params: { + formateurEmail: string; + formateurNom: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + apprenantEtablissement?: string; + formationNom: string; + sequenceNom: string; + nbInscrits: number; + capaciteMax: number; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + + const datesHTML = params.dates.map(date => ` +
  • Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    🎓 Nouvelle inscription à votre formation

    +

    Bonjour ${params.formateurNom},

    + +

    Un nouvel apprenant vient de s'inscrire à votre formation.

    + +
    +

    Détails de l'inscription

    +

    Apprenant : ${params.apprenantPrenom} ${params.apprenantNom}

    +

    Fonction : ${fonctionLabel}

    + ${params.apprenantEtablissement ? `

    Établissement : ${params.apprenantEtablissement}

    ` : ''} +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +
    + +
    +

    📊 État des inscriptions

    +

    ${params.nbInscrits} / ${params.capaciteMax} places occupées

    +
    +
    +
    +
    + +

    Dates de la formation :

    +
      ${datesHTML}
    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `Nouvelle inscription - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une notification au formateur pour une annulation d'inscription + */ +export async function sendNotificationFormateurAnnulation(params: { + formateurEmail: string; + formateurNom: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + nbInscrits: number; + capaciteMax: number; + raisonAnnulation?: string; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre'; + + const content = ` +

    ⚠️ Annulation d'inscription

    +

    Bonjour ${params.formateurNom},

    + +

    Un apprenant a annulé son inscription à votre formation.

    + +
    +

    Détails de l'annulation

    +

    Apprenant : ${params.apprenantPrenom} ${params.apprenantNom}

    +

    Fonction : ${fonctionLabel}

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.raisonAnnulation ? `

    Raison : ${params.raisonAnnulation}

    ` : ''} +
    + +
    +

    📊 État des inscriptions après annulation

    +

    ${params.nbInscrits} / ${params.capaciteMax} places occupées

    +

    ${params.capaciteMax - params.nbInscrits} place(s) disponible(s)

    +
    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `Annulation d'inscription - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une alerte aux admins quand la capacité maximale est atteinte + */ +export async function sendAlerteCapaciteAtteinte(params: { + adminEmails: string[]; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + nbListeAttente: number; + formateurNom?: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise<{ sent: number; failed: number }> { + const datesHTML = params.dates.map(date => ` +
  • ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    🔔 Alerte : Capacité maximale atteinte

    + +
    +

    ⚠️ La séquence est complète

    +

    La capacité maximale de la séquence ${params.sequenceNom} a été atteinte.

    +
    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.formateurNom ? `

    Formateur : ${params.formateurNom}

    ` : ''} +

    Capacité : ${params.nbInscrits} / ${params.capaciteMax} (100%)

    + ${params.nbListeAttente > 0 ? `

    Liste d'attente : ${params.nbListeAttente} personne(s)

    ` : ''} +
    + +

    Dates prévues :

    +
      ${datesHTML}
    + +
    +

    Actions possibles

    +
      +
    • Augmenter la capacité maximale de la séquence
    • +
    • Créer une nouvelle séquence pour cette formation
    • +
    • Contacter les personnes en liste d'attente pour les informer
    • +
    +
    + +

    Cordialement,
    Le système de gestion des formations

    + `; + + let sent = 0; + let failed = 0; + + for (const adminEmail of params.adminEmails) { + try { + await sendEmail({ + to: adminEmail, + subject: `🔔 Capacité atteinte - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'alerte_admin'), + }); + sent++; + } catch (error) { + console.error(`Erreur envoi alerte à ${adminEmail}:`, error); + failed++; + } + } + + return { sent, failed }; +} + +/** + * Envoie une notification aux apprenants en liste d'attente quand une place se libère + */ +export async function sendNotificationPlaceDisponible(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + positionListeAttente: number; + lienConfirmation?: string; + delaiReponse?: number; // en heures +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + const delai = params.delaiReponse || 48; + + const content = ` +

    🎉 Une place s'est libérée !

    +

    Bonjour ${salutation},

    + +

    Bonne nouvelle ! Une place vient de se libérer pour la formation à laquelle vous êtes inscrit(e) en liste d'attente.

    + +
    +

    ✅ Vous pouvez maintenant confirmer votre inscription

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Votre position : ${params.positionListeAttente}${params.positionListeAttente === 1 ? 'er' : 'ème'} sur la liste d'attente

    +
    + + ${params.lienConfirmation ? ` + + ` : '

    Veuillez contacter le service RH pour confirmer votre inscription.

    '} + +
    +

    ⏰ Important

    +

    Vous avez ${delai} heures pour confirmer votre inscription.

    +

    Passé ce délai, la place sera proposée à la personne suivante sur la liste d'attente.

    +
    + +

    Cordialement,
    L'équipe Formation

    + `; + + const variables = { + nomApprenant: params.apprenantNom, + prenomApprenant: params.apprenantPrenom, + nomFormation: params.formationNom, + nomSequence: params.sequenceNom, + }; + + return sendEmail({ + to: params.apprenantEmail, + subject: `🎉 Place disponible - ${params.formationNom}`, + html: await getEmailTemplate(content, 'notification_liste_attente', variables), + }); +} + +/** + * Envoie une notification à un apprenant en liste d'attente pour l'informer d'une annulation de séquence + */ +export async function sendNotificationAnnulationListeAttente(params: { + apprenantEmail: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantFonction: string; + formationNom: string; + sequenceNom: string; + raisonAnnulation?: string; + alternativesDisponibles?: Array<{ sequenceNom: string; nbPlaces: number }>; +}): Promise { + const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' : + params.apprenantFonction === 'chef_service' ? 'Chef de service' : ''; + const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom; + + const alternativesHTML = params.alternativesDisponibles && params.alternativesDisponibles.length > 0 + ? ` +
    +

    📋 Séquences alternatives disponibles

    +
      + ${params.alternativesDisponibles.map(alt => ` +
    • ${alt.sequenceNom} - ${alt.nbPlaces} place(s) disponible(s)
    • + `).join('')} +
    +

    Contactez le service RH pour vous inscrire à une autre séquence.

    +
    + ` : ''; + + const content = ` +

    Information : Annulation de séquence

    +

    Bonjour ${salutation},

    + +

    Nous vous informons que la séquence de formation pour laquelle vous étiez en liste d'attente a été annulée.

    + +
    +

    Séquence annulée

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    + ${params.raisonAnnulation ? `

    Raison : ${params.raisonAnnulation}

    ` : ''} +
    + + ${alternativesHTML} + +

    Nous nous excusons pour ce désagrément et restons à votre disposition pour toute question.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.apprenantEmail, + subject: `Information : Annulation de séquence - ${params.formationNom}`, + html: await getEmailTemplate(content, 'notification_annulation'), + }); +} + +/** + * Envoie une notification au formateur quand la capacité maximale est atteinte + */ +export async function sendNotificationFormateurCapaciteAtteinte(params: { + formateurEmail: string; + formateurNom: string; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise { + const datesHTML = params.dates.map(date => ` +
  • Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    ⚠️ Capacité maximale atteinte

    +

    Bonjour ${params.formateurNom},

    + +

    La capacité maximale de votre formation a été atteinte.

    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Capacité : ${params.nbInscrits} / ${params.capaciteMax}

    +
    + +

    Dates de la formation :

    +
      ${datesHTML}
    + +

    Les nouvelles inscriptions seront automatiquement placées en liste d'attente.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `⚠️ Capacité maximale atteinte - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une notification au formateur quand une place se libère + */ +export async function sendNotificationFormateurPlaceDisponible(params: { + formateurEmail: string; + formateurNom: string; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + placesDisponibles: number; +}): Promise { + const content = ` +

    ✅ Place disponible

    +

    Bonjour ${params.formateurNom},

    + +

    Une place s'est libérée dans votre formation suite à une annulation.

    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Places occupées : ${params.nbInscrits} / ${params.capaciteMax}

    +

    Places disponibles : ${params.placesDisponibles}

    +
    + +

    De nouvelles inscriptions peuvent maintenant être acceptées.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `✅ Place disponible - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une attestation de formation par email + */ +export async function sendAttestationEmail(params: { + to: string; + apprenantNom: string; + apprenantPrenom: string; + formationNom: string; + pdfUrl: string; +}): Promise { + const content = ` +

    Votre attestation de formation

    + +

    Bonjour ${params.apprenantPrenom} ${params.apprenantNom},

    + +

    Nous avons le plaisir de vous transmettre votre attestation de formation pour :

    + +
    +

    ${params.formationNom}

    +
    + +

    Vous pouvez télécharger votre attestation en cliquant sur le lien ci-dessous :

    + + + +

    Cette attestation certifie votre participation à la formation et peut être utilisée pour votre dossier professionnel.

    + +

    Nous vous remercions pour votre participation et vous souhaitons une excellente continuation.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.to, + subject: `Votre attestation de formation - ${params.formationNom}`, + html: await getEmailTemplate(content, 'attestation'), + }); +} diff --git a/deployment-package/source_server/emailTemplateGenerator.ts b/deployment-package/source_server/emailTemplateGenerator.ts new file mode 100644 index 0000000..9143f92 --- /dev/null +++ b/deployment-package/source_server/emailTemplateGenerator.ts @@ -0,0 +1,180 @@ +/** + * Générateur de templates d'emails personnalisés + * Utilise les templates stockés en base de données + */ + +import * as db from "./db"; +import { EmailTemplate } from "../drizzle/schema"; +import { replaceEmailVariables, EmailVariables } from "./emailTemplateUtils"; + +/** + * Génère le HTML d'un email en utilisant le template personnalisé + * @param templateType - Type de template (à utiliser) + * @param content - Contenu de l'email (legacy, sera remplacé par bodyContent) + * @param variables - Variables à remplacer dans le template + */ +export async function generateEmailFromTemplate( + templateType: string, + content: string, + variables?: EmailVariables +): Promise { + // Récupérer le template depuis la base de données + const template = await db.getEmailTemplateByType(templateType); + + // Si pas de template trouvé, utiliser le template par défaut + if (!template) { + const defaultContent = variables ? replaceEmailVariables(content, variables) : content; + return getDefaultEmailTemplate(defaultContent); + } + + return buildEmailHTML(template, content, variables); +} + +/** + * Construit le HTML de l'email avec le template + */ +function buildEmailHTML(template: EmailTemplate, content: string, variables?: EmailVariables): string { + // Utiliser bodyContent si disponible, sinon utiliser content (legacy) + let emailBody = template.bodyContent || content; + + // Remplacer les variables si fournies + if (variables) { + emailBody = replaceEmailVariables(emailBody, variables); + // Remplacer aussi dans le titre et le pied de page + template = { + ...template, + headerTitle: replaceEmailVariables(template.headerTitle, variables), + footerText: template.footerText ? replaceEmailVariables(template.footerText, variables) : template.footerText, + }; + } + return ` + + + + + + + +
    +
    + ${template.logoUrl ? `Logo` : ''} +

    ${template.headerTitle}

    +
    +
    + ${emailBody} +
    + +
    + + + `.trim(); +} + +/** + * Template par défaut si aucun template personnalisé n'est trouvé + */ +function getDefaultEmailTemplate(content: string): string { + return ` + + + + + + + +
    +
    +

    Formation Manager Itinova

    +
    +
    + ${content} +
    + +
    + + + `.trim(); +} diff --git a/deployment-package/source_server/emailTemplateUtils.ts b/deployment-package/source_server/emailTemplateUtils.ts new file mode 100644 index 0000000..ba6d593 --- /dev/null +++ b/deployment-package/source_server/emailTemplateUtils.ts @@ -0,0 +1,66 @@ +/** + * Utilitaires pour le remplacement des variables dans les templates d'emails + */ + +export interface EmailVariables { + nomApprenant?: string; + prenomApprenant?: string; + nomFormation?: string; + nomSequence?: string; + dateDebut?: string; + dateFin?: string; + datesHTML?: string; // HTML formaté de toutes les dates de la séquence + lieu?: string; + formateur?: string; + lienInscription?: string; + [key: string]: string | undefined; +} + +/** + * Remplace les variables {{variable}} dans un texte par leurs valeurs + * @param template - Le texte contenant les variables à remplacer + * @param variables - Un objet contenant les valeurs des variables + * @returns Le texte avec les variables remplacées + */ +export function replaceEmailVariables( + template: string, + variables: EmailVariables +): string { + let result = template; + + // Remplacer chaque variable trouvée dans le template + Object.keys(variables).forEach((key) => { + const value = variables[key]; + if (value !== undefined && value !== null) { + // Remplacer toutes les occurrences de {{key}} par la valeur + const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + result = result.replace(regex, value); + } + }); + + // Nettoyer les variables non remplacées (optionnel) + // result = result.replace(/\{\{[^}]+\}\}/g, ''); + + return result; +} + +/** + * Génère un aperçu d'email avec des données d'exemple + * @param template - Le template d'email + * @returns Le template avec des données d'exemple + */ +export function generateEmailPreview(template: string): string { + const exampleVariables: EmailVariables = { + nomApprenant: 'Dupont', + prenomApprenant: 'Marie', + nomFormation: 'Formation Management', + nomSequence: 'Séquence 1 - Introduction', + dateDebut: '15/01/2025', + dateFin: '17/01/2025', + lieu: 'Salle de formation A - Bâtiment principal', + formateur: 'Jean Martin', + lienInscription: 'https://exemple.com/inscription/abc123', + }; + + return replaceEmailVariables(template, exampleVariables); +} diff --git a/deployment-package/source_server/etablissementsDb.ts b/deployment-package/source_server/etablissementsDb.ts new file mode 100644 index 0000000..4ac3a44 --- /dev/null +++ b/deployment-package/source_server/etablissementsDb.ts @@ -0,0 +1,110 @@ +import { eq, sql } from "drizzle-orm"; +import { getDb } from "./db"; +import { apprenants, inscriptions } from "../drizzle/schema"; + +export async function getEtablissementsStats() { + const db = await getDb(); + if (!db) return []; + + // Récupérer tous les apprenants groupés par établissement + const apprenantsData = await db.select().from(apprenants); + + // Grouper par code établissement + const etablissementsMap = new Map(); + + for (const apprenant of apprenantsData) { + const code = apprenant.codeEtablissement; + + if (!etablissementsMap.has(code)) { + etablissementsMap.set(code, { + codeEtablissement: code, + nombreApprenants: 0, + apprenantsActifs: 0, + apprenantIds: [], + }); + } + + const etab = etablissementsMap.get(code)!; + etab.nombreApprenants++; + etab.apprenantIds.push(apprenant.id); + } + + // Pour chaque établissement, compter les apprenants actifs + const etablissementsStats = await Promise.all( + Array.from(etablissementsMap.values()).map(async (etab) => { + // Compter le nombre d'apprenants ayant au moins une inscription + const apprenantsActifsCount = await Promise.all( + etab.apprenantIds.map(async (apprenantId) => { + const inscriptionsData = await db + .select() + .from(inscriptions) + .where(eq(inscriptions.apprenantId, apprenantId)) + .limit(1); + return inscriptionsData.length > 0 ? 1 : 0; + }) + ); + + const nombreActifs = apprenantsActifsCount.reduce((sum: number, count) => sum + count, 0); + const tauxParticipation = etab.nombreApprenants > 0 + ? Math.round((nombreActifs / etab.nombreApprenants) * 100) + : 0; + + return { + codeEtablissement: etab.codeEtablissement, + nombreApprenants: etab.nombreApprenants, + apprenantsActifs: nombreActifs, + tauxParticipation, + }; + }) + ); + + // Trier par nombre d'apprenants décroissant + return etablissementsStats.sort((a, b) => b.nombreApprenants - a.nombreApprenants); +} + +export async function getEtablissementDetail(codeEtablissement: string) { + const db = await getDb(); + if (!db) return null; + + // Récupérer tous les apprenants de cet établissement + const apprenantsData = await db + .select() + .from(apprenants) + .where(eq(apprenants.codeEtablissement, codeEtablissement)); + + // Pour chaque apprenant, récupérer ses inscriptions + const apprenantsWithInscriptions = await Promise.all( + apprenantsData.map(async (apprenant) => { + const inscriptionsData = await db + .select() + .from(inscriptions) + .where(eq(inscriptions.apprenantId, apprenant.id)); + + return { + ...apprenant, + inscriptions: inscriptionsData, + }; + }) + ); + + const nombreApprenants = apprenantsData.length; + const apprenantsActifs = apprenantsWithInscriptions.filter( + (a) => a.inscriptions.length > 0 + ).length; + const tauxParticipation = nombreApprenants > 0 + ? Math.round((apprenantsActifs / nombreApprenants) * 100) + : 0; + + return { + codeEtablissement, + nombreApprenants, + apprenantsActifs, + tauxParticipation, + apprenants: apprenantsWithInscriptions, + }; +} diff --git a/deployment-package/source_server/exportService.ts b/deployment-package/source_server/exportService.ts new file mode 100644 index 0000000..c85f5e1 --- /dev/null +++ b/deployment-package/source_server/exportService.ts @@ -0,0 +1,277 @@ +/** + * Service d'export des données en Excel et PDF + */ + +import * as XLSX from 'xlsx'; +import { jsPDF } from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface InscriptionExport { + nom: string; + prenom: string; + email: string; + codeEtablissement: string; + fonction: string; + statut: string; + dateInscription: Date; +} + +interface SequenceInfo { + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu: string; + publicCible: string; + inscriptions: InscriptionExport[]; +} + +interface ApprenantPresence { + nom: string; + prenom: string; + codeEtablissement: string; + fonction: string; +} + +interface FeuillePresenceInfo { + formationNom: string; + sequenceNom: string; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; + lieu: string; + publicCible: string; + formateur?: string; + apprenants: ApprenantPresence[]; +} + +/** + * Génère un fichier Excel avec la liste des inscrits + */ +export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer { + // Préparer les données + const data = sequenceInfo.inscriptions.map(i => ({ + 'Nom': i.nom, + 'Prénom': i.prenom, + 'Email': i.email, + 'Code établissement': i.codeEtablissement, + 'Fonction': i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', + 'Statut': i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée', + 'Date d\'inscription': i.dateInscription.toLocaleDateString('fr-FR'), + })); + + // Créer le workbook + const ws = XLSX.utils.json_to_sheet(data); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Inscrits'); + + // Ajouter une feuille d'informations + const infoData = [ + { 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom }, + { 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom }, + { 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu }, + { '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() }, + ]; + + // Ajouter les dates + sequenceInfo.dates.forEach(date => { + infoData.push({ + 'Information': `Date ${date.ordre}`, + 'Valeur': date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + }); + }); + + const wsInfo = XLSX.utils.json_to_sheet(infoData); + XLSX.utils.book_append_sheet(wb, wsInfo, 'Informations'); + + // Générer le buffer + return Buffer.from(XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })); +} + +/** + * Génère un PDF avec la liste des inscrits + */ +export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { + const doc = new jsPDF(); + + // Titre + doc.setFontSize(18); + doc.text('Liste des inscrits', 14, 20); + + // Informations de séquence + doc.setFontSize(10); + let yPos = 35; + doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos); + yPos += 6; + doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos); + yPos += 6; + doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); + yPos += 6; + doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); + yPos += 8; + + // Dates de formation + doc.setFontSize(9); + sequenceInfo.dates.forEach(date => { + doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 14, yPos); + yPos += 5; + }); + + // Tableau des inscrits + const tableData = sequenceInfo.inscriptions.map(i => [ + i.nom, + i.prenom, + i.email, + i.codeEtablissement, + i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', + i.statut === 'confirmee' ? 'Confirmée' : i.statut === 'liste_attente' ? 'Liste d\'attente' : 'Annulée', + ]); + + autoTable(doc, { + startY: yPos + 10, + head: [['Nom', 'Prénom', 'Email', 'Code étab.', 'Fonction', 'Statut']], + body: tableData, + styles: { fontSize: 9 }, + headStyles: { fillColor: [37, 99, 235] }, + }); + + return Buffer.from(doc.output('arraybuffer')); +} + +/** + * Génère une feuille de présence PDF avec signatures matin/après-midi pour chaque journée + */ +export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer { + const doc = new jsPDF(); + + // Charger le logo + let logoData: string | null = null; + + try { + // Utiliser un chemin absolu depuis la racine du projet + const logoPath = path.resolve(process.cwd(), 'client/public/itinova-logo.png'); + const logoBuffer = fs.readFileSync(logoPath); + logoData = `data:image/png;base64,${logoBuffer.toString('base64')}`; + } catch (error) { + console.warn('Logo Itinova non trouvé, génération sans logo:', error); + } + + // Fonction pour générer une page de feuille de présence pour une journée + const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => { + if (!isFirstPage) { + doc.addPage(); + } + + // Ajouter le logo en haut à droite si disponible + if (logoData) { + doc.addImage(logoData, 'PNG', 160, 10, 40, 15); + } + + // Titre + doc.setFontSize(18); + doc.text('Feuille de présence', 14, 20); + + // Informations de séquence + doc.setFontSize(10); + let yPos = 35; + doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos); + yPos += 6; + doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos); + yPos += 6; + doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos); + yPos += 6; + + // Ajouter le formateur si disponible + if (feuilleInfo.formateur) { + doc.text(`Formateur : ${feuilleInfo.formateur}`, 14, yPos); + yPos += 6; + } + + doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); + yPos += 8; + + // Date de la journée + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text(`Date ${dateInfo.ordre} : ${dateInfo.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}`, 14, yPos); + doc.setFont('helvetica', 'normal'); + yPos += 10; + + // Tableau de présence avec signatures matin et après-midi + const tableData = feuilleInfo.apprenants.map(i => [ + i.nom, + i.prenom, + i.codeEtablissement, + i.fonction === 'directeur' ? 'Directeur' : i.fonction === 'chef_service' ? 'Chef de service' : 'Autre', + '', // Signature matin + '', // Signature après-midi + ]); + + autoTable(doc, { + startY: yPos, + head: [['Nom', 'Prénom', 'Code étab.', 'Fonction', 'Signature\nMatin', 'Signature\nAprès-midi']], + body: tableData, + styles: { + fontSize: 9, + cellPadding: 3, + minCellHeight: 10, + }, + headStyles: { + fillColor: [37, 99, 235], + halign: 'center', + }, + columnStyles: { + 0: { cellWidth: 30 }, // Nom + 1: { cellWidth: 30 }, // Prénom + 2: { cellWidth: 25 }, // Code établissement + 3: { cellWidth: 30 }, // Fonction + 4: { cellWidth: 35 }, // Signature matin + 5: { cellWidth: 35 }, // Signature après-midi + }, + }); + + // Ajouter un champ de signature pour le formateur en bas de page + const finalY = (doc as any).lastAutoTable.finalY || yPos + 50; + const signatureY = finalY + 20; + + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.text('Signature du formateur :', 14, signatureY); + doc.setFont('helvetica', 'normal'); + + // Dessiner une ligne pour la signature + doc.line(60, signatureY, 120, signatureY); + + // Ajouter la date + doc.setFontSize(9); + doc.text('Date : _______________', 140, signatureY); + }; + + // Générer une page pour chaque date + feuilleInfo.dates.forEach((date, index) => { + generatePageForDate(date, index === 0); + }); + + return Buffer.from(doc.output('arraybuffer')); +} diff --git a/deployment-package/source_server/formateurDb.ts b/deployment-package/source_server/formateurDb.ts new file mode 100644 index 0000000..1e5ae2e --- /dev/null +++ b/deployment-package/source_server/formateurDb.ts @@ -0,0 +1,402 @@ +import { eq, sql, and, gte, lte, desc } from "drizzle-orm"; +import { getDb } from "./db"; +import { + sequences, + formations, + formateurs, + datesFormation, + inscriptions, + apprenants, + supportsFormation +} from "../drizzle/schema"; + +/** + * Récupère le calendrier des interventions d'un formateur + * @param formateurId - ID du formateur + * @param dateDebut - Date de début optionnelle + * @param dateFin - Date de fin optionnelle + */ +export async function getCalendrierFormateur(formateurId: number, dateDebut?: Date, dateFin?: Date) { + const db = await getDb(); + if (!db) return []; + + const conditions = [eq(sequences.formateurId, formateurId)]; + + if (dateDebut) { + conditions.push(gte(datesFormation.dateDebut, dateDebut)); + } + if (dateFin) { + conditions.push(lte(datesFormation.dateFin, dateFin)); + } + + const results = await db + .select({ + sequenceId: sequences.id, + sequenceNom: sequences.nom, + formationId: formations.id, + formationNom: formations.nom, + dateId: datesFormation.id, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + ordre: datesFormation.ordre, + lieu: sequences.lieu, + nbInscrits: sql`( + SELECT COUNT(*) + FROM inscriptions + WHERE inscriptions.sequenceId = ${sequences.id} + AND inscriptions.statut IN ('confirmee', 'liste_attente') + )`, + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(datesFormation.dateDebut); + + return results; +} + +/** + * Récupère la liste des apprenants inscrits à une séquence + * @param sequenceId - ID de la séquence + */ +export async function getApprenantsSequence(sequenceId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + inscriptionId: inscriptions.id, + apprenantId: apprenants.id, + nom: apprenants.nom, + prenom: apprenants.prenom, + email: apprenants.email, + fonction: apprenants.fonction, + codeEtablissement: apprenants.codeEtablissement, + statut: inscriptions.statut, + dateInscription: inscriptions.dateInscription, + // presenceValidee n'existe pas dans le schéma actuel + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where(eq(inscriptions.sequenceId, sequenceId)) + .orderBy(apprenants.nom, apprenants.prenom); + + return results; +} + +/** + * Récupère les supports de formation d'une séquence + * @param sequenceId - ID de la séquence + */ +export async function getSupportsSequence(sequenceId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + id: supportsFormation.id, + nomFichier: supportsFormation.nomFichier, + typeFichier: supportsFormation.typeFichier, + tailleFichier: supportsFormation.tailleFichier, + urlFichier: supportsFormation.urlFichier, + description: supportsFormation.description, + formateurNom: formateurs.nom, + createdAt: supportsFormation.createdAt, + }) + .from(supportsFormation) + .innerJoin(formateurs, eq(supportsFormation.formateurId, formateurs.id)) + .where(eq(supportsFormation.sequenceId, sequenceId)) + .orderBy(desc(supportsFormation.createdAt)); + + return results; +} + +/** + * Ajoute un support de formation + * @param support - Données du support à ajouter + */ +export async function ajouterSupport(support: { + sequenceId: number; + formateurId: number; + nomFichier: string; + typeFichier: string; + tailleFichier: number; + urlFichier: string; + s3Key: string; + description?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const [result] = await db.insert(supportsFormation).values(support); + return result; +} + +/** + * Supprime un support de formation + * @param supportId - ID du support à supprimer + * @param formateurId - ID du formateur (pour vérification) + */ +export async function supprimerSupport(supportId: number, formateurId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier que le support appartient bien au formateur + const [support] = await db + .select() + .from(supportsFormation) + .where( + and( + eq(supportsFormation.id, supportId), + eq(supportsFormation.formateurId, formateurId) + ) + ) + .limit(1); + + if (!support) { + throw new Error("Support non trouvé ou non autorisé"); + } + + await db.delete(supportsFormation).where(eq(supportsFormation.id, supportId)); + + return support; // Retourner le support pour récupérer la clé S3 +} + +/** + * Valide la présence d'un apprenant + * @param inscriptionId - ID de l'inscription + * @param present - true si présent, false sinon + */ +export async function validerPresence(inscriptionId: number, present: boolean) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db + .update(inscriptions) + .set({ statut: present ? 'confirmee' : 'annulee' }) + .where(eq(inscriptions.id, inscriptionId)); +} + +/** + * Récupère l'historique des formations d'un formateur + * @param formateurId - ID du formateur + * @param limit - Nombre de résultats à retourner (par défaut 50) + */ +export async function getHistoriqueFormateur(formateurId: number, limit: number = 50) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + sequenceId: sequences.id, + sequenceNom: sequences.nom, + formationNom: formations.nom, + dateDebut: sql`MIN(${datesFormation.dateDebut})`, + dateFin: sql`MAX(${datesFormation.dateFin})`, + lieu: sequences.lieu, + nbInscrits: sql`( + SELECT COUNT(*) + FROM inscriptions + WHERE inscriptions.sequenceId = ${sequences.id} + AND inscriptions.statut IN ('confirmee', 'liste_attente') + )`, + nbPresents: sql`( + SELECT COUNT(*) + FROM inscriptions + WHERE inscriptions.sequenceId = ${sequences.id} + AND inscriptions.statut = 'confirmee' + )`, + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id)) + .where(eq(sequences.formateurId, formateurId)) + .groupBy(sequences.id, sequences.nom, formations.nom, sequences.lieu) + .orderBy(desc(sql`MIN(${datesFormation.dateDebut})`)) + .limit(limit); + + return results; +} + +/** + * Récupère les détails d'une séquence pour un formateur + * @param sequenceId - ID de la séquence + * @param formateurId - ID du formateur (pour vérification) + */ +export async function getDetailSequence(sequenceId: number, formateurId: number) { + const db = await getDb(); + if (!db) return null; + + const [result] = await db + .select({ + id: sequences.id, + nom: sequences.nom, + formationNom: formations.nom, + lieu: sequences.lieu, + capaciteMax: sequences.capaciteMax, + formateurNom: formateurs.nom, + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .innerJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .where( + and( + eq(sequences.id, sequenceId), + eq(sequences.formateurId, formateurId) + ) + ) + .limit(1); + + if (!result) return null; + + // Récupérer les dates de formation + const dates = await db + .select({ + id: datesFormation.id, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + ordre: datesFormation.ordre, + }) + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequenceId)) + .orderBy(datesFormation.dateDebut); + + return { + ...result, + dates, + }; +} + +/** + * Récupérer les statistiques du tableau de bord pour un formateur + */ +export async function getFormateurDashboardStats(formateurId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Nombre total de séquences du formateur + const totalSequences = await db + .select({ count: sql`COUNT(*)` }) + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + // Nombre total d'inscrits confirmés à toutes les séquences du formateur + const totalInscrits = await db + .select({ count: sql`COUNT(*)` }) + .from(inscriptions) + .innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where( + and( + eq(sequences.formateurId, formateurId), + eq(inscriptions.statut, 'confirmee') + ) + ); + + // Nombre de séquences à venir (ayant au moins une date future) + const today = new Date(); + const sequencesAvenir = await db + .select({ sequenceId: datesFormation.sequenceId }) + .from(datesFormation) + .innerJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) + .where( + and( + eq(sequences.formateurId, formateurId), + gte(datesFormation.dateDebut, today) + ) + ) + .groupBy(datesFormation.sequenceId); + + return { + totalSequences: totalSequences[0]?.count || 0, + totalInscrits: totalInscrits[0]?.count || 0, + sequencesAvenir: sequencesAvenir.length, + }; +} + +/** + * Récupérer les prochaines séquences du formateur avec leurs détails + */ +export async function getFormateurProchainesSequences(formateurId: number, limit: number = 10) { + const db = await getDb(); + if (!db) return []; + + const today = new Date(); + + // Récupérer les séquences du formateur ayant au moins une date future + const seqs = await db + .select() + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + // Pour chaque séquence, récupérer les détails + const sequencesAvecDetails = await Promise.all( + seqs.map(async (seq) => { + // Récupérer toutes les dates de la séquence + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, seq.id)) + .orderBy(datesFormation.dateDebut); + + // Vérifier si au moins une date est future + const hasFutureDate = dates.some(d => new Date(d.dateDebut) >= today); + + if (!hasFutureDate) return null; + + // Récupérer la formation + const formation = await db + .select() + .from(formations) + .where(eq(formations.id, seq.formationId)) + .limit(1); + + // Compter les inscrits confirmés + const inscritsCount = await db + .select({ count: sql`COUNT(*)` }) + .from(inscriptions) + .where( + and( + eq(inscriptions.sequenceId, seq.id), + eq(inscriptions.statut, 'confirmee') + ) + ); + + return { + ...seq, + formation: formation[0] || null, + dates, + nbInscrits: inscritsCount[0]?.count || 0, + prochaineDateDebut: dates.find(d => new Date(d.dateDebut) >= today)?.dateDebut || dates[0]?.dateDebut, + }; + }) + ); + + // Filtrer les séquences nulles et trier par prochaine date + const sequencesFiltrees = sequencesAvecDetails + .filter(s => s !== null) + .sort((a, b) => { + const dateA = new Date(a!.prochaineDateDebut); + const dateB = new Date(b!.prochaineDateDebut); + return dateA.getTime() - dateB.getTime(); + }) + .slice(0, limit); + + return sequencesFiltrees; +} + +/** + * Récupérer l'email du formateur à partir de son ID + */ +export async function getFormateurEmailById(formateurId: number): Promise { + const db = await getDb(); + if (!db) return null; + + const result = await db + .select({ email: formateurs.email }) + .from(formateurs) + .where(eq(formateurs.id, formateurId)) + .limit(1); + + return result[0]?.email || null; +} diff --git a/deployment-package/source_server/gestionAttestationsDb.ts b/deployment-package/source_server/gestionAttestationsDb.ts new file mode 100644 index 0000000..25e1374 --- /dev/null +++ b/deployment-package/source_server/gestionAttestationsDb.ts @@ -0,0 +1,202 @@ +import { eq, and, desc, sql } from "drizzle-orm"; +import { getDb } from "./db"; +import { formations, attestations, inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema"; + +/** + * Récupérer la configuration d'une formation + */ +export async function getFormationConfig(formationId: number) { + const db = await getDb(); + if (!db) return null; + + const results = await db + .select({ + id: formations.id, + nom: formations.nom, + modeAttestation: formations.modeAttestation, + modeEnvoi: formations.modeEnvoi, + }) + .from(formations) + .where(eq(formations.id, formationId)) + .limit(1); + + return results.length > 0 ? results[0] : null; +} + +/** + * Mettre à jour la configuration d'une formation + */ +export async function updateFormationConfig( + formationId: number, + modeAttestation: "auto" | "manuel", + modeEnvoi: "auto" | "manuel" +) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db + .update(formations) + .set({ + modeAttestation, + modeEnvoi, + updatedAt: new Date(), + }) + .where(eq(formations.id, formationId)); + + return true; +} + +/** + * Récupérer toutes les attestations d'une formation avec les informations des apprenants + */ +export async function getAttestationsByFormation(formationId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + attestation: attestations, + apprenant: apprenants, + sequence: sequences, + }) + .from(attestations) + .innerJoin(inscriptions, eq(attestations.inscriptionId, inscriptions.id)) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where(eq(sequences.formationId, formationId)) + .orderBy(desc(attestations.createdAt)); + + return results; +} + +/** + * Uploader un document d'attestation pour un apprenant + */ +export async function uploadAttestationDocument( + inscriptionId: number, + documentUrl: string, + documentS3Key: string, + uploadedBy: number +) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier si une attestation existe déjà pour cette inscription + const existing = await db + .select() + .from(attestations) + .where(eq(attestations.inscriptionId, inscriptionId)) + .limit(1); + + if (existing.length > 0) { + // Mettre à jour l'attestation existante + await db + .update(attestations) + .set({ + documentUrl, + documentS3Key, + uploadedBy, + uploadedAt: new Date(), + }) + .where(eq(attestations.inscriptionId, inscriptionId)); + + return existing[0].id; + } else { + // Créer une nouvelle attestation + const result = await db + .insert(attestations) + .values({ + inscriptionId, + documentUrl, + documentS3Key, + uploadedBy, + uploadedAt: new Date(), + }) + .$returningId(); + + return result[0].id; + } +} + +/** + * Envoyer une attestation par email + */ +export async function markAttestationAsSent(attestationId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db + .update(attestations) + .set({ + emailEnvoye: true, + dateEnvoiEmail: new Date(), + }) + .where(eq(attestations.id, attestationId)); + + return true; +} + +/** + * Récupérer les apprenants d'une formation avec leur statut d'attestation + */ +export async function getApprenantsWithAttestationStatus(formationId: number) { + const db = await getDb(); + if (!db) return []; + + const results = await db + .select({ + apprenant: { + id: apprenants.id, + nom: apprenants.nom, + prenom: apprenants.prenom, + email: apprenants.email, + }, + inscription: { + id: inscriptions.id, + sequenceId: inscriptions.sequenceId, + }, + sequence: { + id: sequences.id, + nom: sequences.nom, + }, + attestation: { + id: attestations.id, + documentUrl: attestations.documentUrl, + emailEnvoye: attestations.emailEnvoye, + dateEnvoiEmail: attestations.dateEnvoiEmail, + }, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .leftJoin(attestations, eq(attestations.inscriptionId, inscriptions.id)) + .where( + and( + eq(sequences.formationId, formationId), + eq(inscriptions.statut, "confirmee") + ) + ) + .orderBy(apprenants.nom, apprenants.prenom); + + return results; +} + +/** + * Supprimer un document d'attestation + */ +export async function deleteAttestationDocument(attestationId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db + .update(attestations) + .set({ + documentUrl: null, + documentS3Key: null, + uploadedBy: null, + uploadedAt: null, + }) + .where(eq(attestations.id, attestationId)); + + return true; +} diff --git a/deployment-package/source_server/icsGenerator.ts b/deployment-package/source_server/icsGenerator.ts new file mode 100644 index 0000000..b73fb2c --- /dev/null +++ b/deployment-package/source_server/icsGenerator.ts @@ -0,0 +1,109 @@ +/** + * Module de génération de fichiers ICS (iCalendar) pour les invitations Outlook + */ + +interface ICSEvent { + summary: string; + description?: string; + location: string; + startDate: Date; + endDate: Date; + attendeeEmail: string; + attendeeName: string; + organizerEmail?: string; + organizerName?: string; +} + +/** + * Formate une date au format iCalendar (YYYYMMDDTHHMMSSZ) + */ +function formatICSDate(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}${month}${day}T${hours}${minutes}${seconds}Z`; +} + +/** + * Génère un UID unique pour l'événement + */ +function generateUID(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}@itinova.com`; +} + +/** + * Échappe les caractères spéciaux pour le format ICS + */ +function escapeICS(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +/** + * Génère un fichier ICS pour une invitation Outlook + */ +export function generateICS(event: ICSEvent): string { + const now = new Date(); + const uid = generateUID(); + + const icsContent = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Itinova//Formation Manager//FR', + 'CALSCALE:GREGORIAN', + 'METHOD:REQUEST', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${formatICSDate(now)}`, + `DTSTART:${formatICSDate(event.startDate)}`, + `DTEND:${formatICSDate(event.endDate)}`, + `SUMMARY:${escapeICS(event.summary)}`, + event.description ? `DESCRIPTION:${escapeICS(event.description)}` : '', + `LOCATION:${escapeICS(event.location)}`, + 'STATUS:CONFIRMED', + 'TRANSP:OPAQUE', // Marque comme "occupé" dans le calendrier + 'SEQUENCE:0', + `ORGANIZER;CN=${escapeICS(event.organizerName || 'Formation Itinova')}:mailto:${event.organizerEmail || 'formation@itinova.com'}`, + `ATTENDEE;CN=${escapeICS(event.attendeeName)};RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:${event.attendeeEmail}`, + 'BEGIN:VALARM', + 'TRIGGER:-P1D', // Rappel 1 jour avant + 'ACTION:DISPLAY', + `DESCRIPTION:Rappel: ${escapeICS(event.summary)}`, + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + ].filter(line => line !== '').join('\r\n'); + + return icsContent; +} + +/** + * Génère un fichier ICS pour une session de formation + */ +export function generateFormationICS(params: { + formationNom: string; + sessionNom: string; + dateDebut: Date; + dateFin: Date; + lieu: string; + apprenantNom: string; + apprenantPrenom: string; + apprenantEmail: string; +}): string { + return generateICS({ + summary: `Formation: ${params.formationNom} - ${params.sessionNom}`, + description: `Vous êtes inscrit à la formation "${params.formationNom}".\n\nSession: ${params.sessionNom}\n\nMerci de vous présenter à l'heure indiquée.`, + location: params.lieu, + startDate: params.dateDebut, + endDate: params.dateFin, + attendeeEmail: params.apprenantEmail, + attendeeName: `${params.apprenantPrenom} ${params.apprenantNom}`, + }); +} diff --git a/deployment-package/source_server/importExcel.ts b/deployment-package/source_server/importExcel.ts new file mode 100644 index 0000000..270fa15 --- /dev/null +++ b/deployment-package/source_server/importExcel.ts @@ -0,0 +1,331 @@ +import * as XLSX from 'xlsx'; +import { getDb } from './db'; +import { formations as formationsTable, sequences as sequencesTable, datesFormation } from '../drizzle/schema'; + +export interface FormationImport { + nom: string; + description: string; + publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre'; +} + +export interface SequenceImport { + formationNom: string; + nom: string; + lieu: string; + publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre'; + capaciteMax: number; + dateBlocage: string; + statut: 'ouverte' | 'bloquee' | 'terminee'; + dates: Array<{ debut: string; fin: string }>; +} + +export interface ImportResult { + success: boolean; + formationsCreated: number; + sequencesCreated: number; + errors: string[]; + warnings: string[]; +} + +/** + * Parse un fichier Excel et retourne les données structurées + */ +export function parseExcelFile(buffer: Buffer): { formations: FormationImport[]; sequences: SequenceImport[] } { + const workbook = XLSX.read(buffer, { type: 'buffer' }); + + const formations: FormationImport[] = []; + const sequences: SequenceImport[] = []; + + // Parser la feuille "Formations" + if (workbook.SheetNames.includes('Formations')) { + const sheet = workbook.Sheets['Formations']; + const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + + // Ignorer la première ligne (en-têtes) et les lignes vides + for (let i = 1; i < data.length; i++) { + const row = data[i]; + if (!row || row.length === 0 || !row[0]) continue; + + // Arrêter si on atteint les instructions + if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break; + + formations.push({ + nom: String(row[0] || '').trim(), + description: String(row[1] || '').trim(), + publicCible: normalizePublicCible(String(row[2] || '').trim()), + }); + } + } + + // Parser la feuille "Séquences" + if (workbook.SheetNames.includes('Séquences')) { + const sheet = workbook.Sheets['Séquences']; + const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + + for (let i = 1; i < data.length; i++) { + const row = data[i]; + if (!row || row.length === 0 || !row[0]) continue; + + // Arrêter si on atteint les instructions + if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break; + + const dates: Array<{ debut: string; fin: string }> = []; + + // Parser les 4 dates possibles (colonnes 7-14) + for (let d = 0; d < 4; d++) { + const debutIdx = 7 + (d * 2); + const finIdx = 8 + (d * 2); + + if (row[debutIdx] && row[finIdx]) { + dates.push({ + debut: parseExcelDate(row[debutIdx]), + fin: parseExcelDate(row[finIdx]), + }); + } + } + + sequences.push({ + formationNom: String(row[0] || '').trim(), + nom: String(row[1] || '').trim(), + lieu: String(row[2] || '').trim(), + publicCible: normalizePublicCible(String(row[3] || '').trim()), + capaciteMax: parseInt(String(row[4] || '12')), + dateBlocage: parseExcelDate(row[5]), + statut: normalizeStatut(String(row[6] || '').trim()), + dates, + }); + } + } + + return { formations, sequences }; +} + +/** + * Valide les données importées + */ +export function validateImportData( + formations: FormationImport[], + sequences: SequenceImport[] +): { valid: boolean; errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + // Valider les formations + formations.forEach((formation, index) => { + if (!formation.nom) { + errors.push(`Formation ligne ${index + 2}: Le nom est obligatoire`); + } + if (!formation.description) { + errors.push(`Formation ligne ${index + 2}: La description est obligatoire`); + } + if (!['directeur', 'chef_service', 'tous', 'autre'].includes(formation.publicCible)) { + errors.push(`Formation ligne ${index + 2}: Public cible invalide (${formation.publicCible})`); + } + }); + + // Créer un set des noms de formations pour validation des séquences + const formationNames = new Set(formations.map(f => f.nom.toLowerCase())); + + // Valider les séquences + sequences.forEach((sequence, index) => { + if (!sequence.formationNom) { + errors.push(`Séquence ligne ${index + 2}: Le nom de la formation est obligatoire`); + } else if (!formationNames.has(sequence.formationNom.toLowerCase())) { + errors.push(`Séquence ligne ${index + 2}: Formation "${sequence.formationNom}" non trouvée dans la feuille Formations`); + } + + if (!sequence.nom) { + errors.push(`Séquence ligne ${index + 2}: Le nom est obligatoire`); + } + if (!sequence.lieu) { + errors.push(`Séquence ligne ${index + 2}: Le lieu est obligatoire`); + } + if (!['directeur', 'chef_service', 'tous', 'autre'].includes(sequence.publicCible)) { + errors.push(`Séquence ligne ${index + 2}: Public cible invalide`); + } + if (!sequence.dateBlocage) { + errors.push(`Séquence ligne ${index + 2}: La date de blocage est obligatoire`); + } + if (!['ouverte', 'bloquee', 'terminee'].includes(sequence.statut)) { + errors.push(`Séquence ligne ${index + 2}: Statut invalide`); + } + if (sequence.dates.length === 0) { + errors.push(`Séquence ligne ${index + 2}: Au moins une date est obligatoire`); + } + if (sequence.dates.length > 4) { + warnings.push(`Séquence ligne ${index + 2}: Maximum 4 dates autorisées, les dates supplémentaires seront ignorées`); + } + }); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Importe les données dans la base de données + */ +export async function importToDatabase( + formations: FormationImport[], + sequences: SequenceImport[] +): Promise { + const db = await getDb(); + if (!db) { + return { + success: false, + formationsCreated: 0, + sequencesCreated: 0, + errors: ['Connexion à la base de données impossible'], + warnings: [], + }; + } + + const errors: string[] = []; + const warnings: string[] = []; + let formationsCreated = 0; + let sequencesCreated = 0; + + try { + // Map pour stocker les IDs des formations créées + const formationIdMap = new Map(); + + // Importer les formations + console.log(`[Import] Début import de ${formations.length} formations`); + for (const formation of formations) { + try { + console.log(`[Import] Création formation: ${formation.nom}`); + const [result] = await db.insert(formationsTable).values({ + nom: formation.nom, + description: formation.description, + lienUnique: generateUniqueLien(), + }).$returningId(); + + const formationId = result.id; + formationIdMap.set(formation.nom.toLowerCase(), formationId); + console.log(`[Import] Formation créée avec ID ${formationId}, ajoutée à la map avec clé: "${formation.nom.toLowerCase()}"`); + formationsCreated++; + } catch (error: any) { + console.error(`[Import] Erreur formation "${formation.nom}":`, error); + errors.push(`Erreur lors de la création de la formation "${formation.nom}": ${error.message}`); + } + } + console.log(`[Import] Formations créées: ${formationsCreated}, Map size: ${formationIdMap.size}`); + console.log(`[Import] Clés dans la map:`, Array.from(formationIdMap.keys())); + + // Si des erreurs sont survenues lors de la création des formations, arrêter l'import + if (errors.length > 0) { + console.error(`[Import] Arrêt de l'import car ${errors.length} erreur(s) lors de la création des formations`); + return { + success: false, + formationsCreated, + sequencesCreated: 0, + errors, + warnings, + }; + } + + // Importer les séquences + console.log(`[Import] Début import de ${sequences.length} séquences`); + for (const sequence of sequences) { + try { + const searchKey = sequence.formationNom.toLowerCase(); + console.log(`[Import] Recherche formation pour séquence "${sequence.nom}" avec clé: "${searchKey}"`); + const formationId = formationIdMap.get(searchKey); + if (!formationId) { + errors.push(`Formation "${sequence.formationNom}" non trouvée pour la séquence "${sequence.nom}"`); + continue; + } + + const [result] = await db.insert(sequencesTable).values({ + formationId, + nom: sequence.nom, + lieu: sequence.lieu, + publicCible: sequence.publicCible, + capaciteMax: sequence.capaciteMax, + dateBlocage: new Date(sequence.dateBlocage), + statut: sequence.statut, + }).$returningId(); + + const sequenceId = result.id; + + // Insérer les dates de formation + for (let i = 0; i < Math.min(sequence.dates.length, 4); i++) { + const date = sequence.dates[i]; + await db.insert(datesFormation).values({ + sequenceId, + dateDebut: new Date(date.debut), + dateFin: new Date(date.fin), + ordre: i + 1, + }); + } + + sequencesCreated++; + } catch (error: any) { + errors.push(`Erreur lors de la création de la séquence "${sequence.nom}": ${error.message}`); + } + } + + return { + success: errors.length === 0, + formationsCreated, + sequencesCreated, + errors, + warnings, + }; + } catch (error: any) { + return { + success: false, + formationsCreated, + sequencesCreated, + errors: [`Erreur générale: ${error.message}`], + warnings, + }; + } +} + +/** + * Normalise le public cible + */ +function normalizePublicCible(value: string): 'directeur' | 'chef_service' | 'tous' | 'autre' { + const normalized = value.toLowerCase().trim(); + if (normalized === 'directeur') return 'directeur'; + if (normalized === 'chef_service' || normalized === 'chef de service') return 'chef_service'; + if (normalized === 'tous') return 'tous'; + return 'autre'; +} + +/** + * Normalise le statut + */ +function normalizeStatut(value: string): 'ouverte' | 'bloquee' | 'terminee' { + const normalized = value.toLowerCase().trim(); + if (normalized === 'ouverte') return 'ouverte'; + if (normalized === 'bloquee' || normalized === 'bloquée') return 'bloquee'; + if (normalized === 'terminee' || normalized === 'terminée') return 'terminee'; + return 'ouverte'; +} + +/** + * Parse une date Excel (peut être un nombre de série Excel ou une chaîne) + */ +function parseExcelDate(value: any): string { + if (!value) return ''; + + // Si c'est un nombre (date Excel) + if (typeof value === 'number') { + const date = XLSX.SSF.parse_date_code(value); + return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H || 0).padStart(2, '0')}:${String(date.M || 0).padStart(2, '0')}`; + } + + // Si c'est déjà une chaîne + return String(value).trim(); +} + +/** + * Génère un lien unique pour une formation + */ +function generateUniqueLien(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); +} diff --git a/deployment-package/source_server/inscriptionWithDates.ts b/deployment-package/source_server/inscriptionWithDates.ts new file mode 100644 index 0000000..cfcd627 --- /dev/null +++ b/deployment-package/source_server/inscriptionWithDates.ts @@ -0,0 +1,85 @@ +import { eq, ne, and } from "drizzle-orm"; +import { inscriptions, apprenants, sequences, datesFormation } from "../drizzle/schema"; +import { getDb } from "./db"; + +/** + * Récupérer les inscriptions d'une séquence avec les dates de formation + */ +export async function getInscriptionsWithDates(sequenceId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer les inscriptions + const results = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequenceId), + ne(inscriptions.statut, "annulee") + ) + ); + + // Récupérer les dates de formation pour cette séquence + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequenceId)) + .orderBy(datesFormation.ordre); + + // Combiner les données + return results.map((r) => ({ + ...r, + dates, + })); +} + +/** + * Récupérer les inscriptions d'un apprenant avec les détails des séquences et dates + */ +export async function getInscriptionsByApprenantWithDates(apprenantId: number) { + const db = await getDb(); + if (!db) return []; + + // Récupérer les inscriptions + const results = await db + .select({ + inscription: inscriptions, + sequence: sequences, + }) + .from(inscriptions) + .leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where( + and( + eq(inscriptions.apprenantId, apprenantId), + ne(inscriptions.statut, "annulee") + ) + ); + + // Pour chaque inscription, récupérer les dates de formation + const resultsWithDates = await Promise.all( + results.map(async (r) => { + if (!r.sequence) return { ...r, dates: [] }; + + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, r.sequence.id)) + .orderBy(datesFormation.ordre); + + return { + ...r, + sequence: { + ...r.sequence, + dates, + }, + }; + }) + ); + + return resultsWithDates; +} diff --git a/deployment-package/source_server/notificationLogsDb.ts b/deployment-package/source_server/notificationLogsDb.ts new file mode 100644 index 0000000..e1f19a9 --- /dev/null +++ b/deployment-package/source_server/notificationLogsDb.ts @@ -0,0 +1,210 @@ +import { eq, desc, and, gte, lte, sql, like } from "drizzle-orm"; +import { getDb } from "./db"; +import { logsNotifications, sequences, apprenants, formateurs, formations } from "../drizzle/schema"; + +export type NotificationType = + | "remerciement" + | "notification_formateur_inscription" + | "notification_formateur_annulation" + | "alerte_capacite" + | "notification_liste_attente"; + +export interface LogNotificationInput { + type: NotificationType; + sequenceId?: number; + apprenantId?: number; + formateurId?: number; + emailDestinataire: string; + sujet: string; + statut: "success" | "failed"; + messageErreur?: string; + metadata?: Record; +} + +/** + * Enregistre un log de notification + */ +export async function logNotification(input: LogNotificationInput) { + const db = await getDb(); + if (!db) { + console.warn("[NotificationLogs] Database not available"); + return; + } + + try { + await db.insert(logsNotifications).values({ + type: input.type, + sequenceId: input.sequenceId || null, + apprenantId: input.apprenantId || null, + formateurId: input.formateurId || null, + emailDestinataire: input.emailDestinataire, + sujet: input.sujet, + statut: input.statut, + messageErreur: input.messageErreur || null, + metadata: input.metadata ? JSON.stringify(input.metadata) : null, + }); + console.log(`[NotificationLogs] Log créé: ${input.type} -> ${input.emailDestinataire} (${input.statut})`); + } catch (error) { + console.error("[NotificationLogs] Erreur lors de la création du log:", error); + } +} + +/** + * Récupère l'historique des notifications avec filtres + */ +export async function getNotificationLogs(filters?: { + type?: NotificationType; + dateDebut?: Date; + dateFin?: Date; + statut?: "success" | "failed"; + email?: string; + sequenceId?: number; + limit?: number; + offset?: number; +}) { + const db = await getDb(); + if (!db) return { logs: [], total: 0 }; + + const conditions = []; + + if (filters?.type) { + conditions.push(eq(logsNotifications.type, filters.type)); + } + if (filters?.dateDebut) { + conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut)); + } + if (filters?.dateFin) { + conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin)); + } + if (filters?.statut) { + conditions.push(eq(logsNotifications.statut, filters.statut)); + } + if (filters?.email) { + conditions.push(like(logsNotifications.emailDestinataire, `%${filters.email}%`)); + } + if (filters?.sequenceId) { + conditions.push(eq(logsNotifications.sequenceId, filters.sequenceId)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Récupérer les logs avec les informations associées + const logs = await db + .select({ + id: logsNotifications.id, + type: logsNotifications.type, + sequenceId: logsNotifications.sequenceId, + apprenantId: logsNotifications.apprenantId, + formateurId: logsNotifications.formateurId, + emailDestinataire: logsNotifications.emailDestinataire, + sujet: logsNotifications.sujet, + dateEnvoi: logsNotifications.dateEnvoi, + statut: logsNotifications.statut, + messageErreur: logsNotifications.messageErreur, + metadata: logsNotifications.metadata, + sequenceNom: sequences.nom, + formationNom: formations.nom, + apprenantNom: apprenants.nom, + apprenantPrenom: apprenants.prenom, + formateurNom: formateurs.nom, + }) + .from(logsNotifications) + .leftJoin(sequences, eq(logsNotifications.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(apprenants, eq(logsNotifications.apprenantId, apprenants.id)) + .leftJoin(formateurs, eq(logsNotifications.formateurId, formateurs.id)) + .where(whereClause) + .orderBy(desc(logsNotifications.dateEnvoi)) + .limit(filters?.limit || 50) + .offset(filters?.offset || 0); + + // Compter le total + const [countResult] = await db + .select({ count: sql`COUNT(*)` }) + .from(logsNotifications) + .where(whereClause); + + return { + logs, + total: countResult?.count || 0, + }; +} + +/** + * Récupère les statistiques des notifications + */ +export async function getNotificationStats(filters?: { + dateDebut?: Date; + dateFin?: Date; +}) { + const db = await getDb(); + if (!db) return null; + + const conditions = []; + if (filters?.dateDebut) { + conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut)); + } + if (filters?.dateFin) { + conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Statistiques globales + const [globalStats] = await db + .select({ + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause); + + // Statistiques par type + const statsByType = await db + .select({ + type: logsNotifications.type, + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause) + .groupBy(logsNotifications.type); + + // Évolution par jour (7 derniers jours) + const evolutionParJour = await db + .select({ + date: sql`DATE(dateEnvoi)`, + total: sql`COUNT(*)`, + success: sql`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`, + failed: sql`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`, + }) + .from(logsNotifications) + .where(whereClause) + .groupBy(sql`DATE(dateEnvoi)`) + .orderBy(sql`DATE(dateEnvoi)`) + .limit(30); + + return { + global: { + total: globalStats?.total || 0, + success: globalStats?.success || 0, + failed: globalStats?.failed || 0, + tauxSucces: globalStats?.total ? Math.round((globalStats.success / globalStats.total) * 100) : 0, + }, + parType: statsByType, + evolutionParJour, + }; +} + +/** + * Labels pour les types de notifications + */ +export const notificationTypeLabels: Record = { + remerciement: "Remerciement post-formation", + notification_formateur_inscription: "Notification formateur (inscription)", + notification_formateur_annulation: "Notification formateur (annulation)", + alerte_capacite: "Alerte capacité atteinte", + notification_liste_attente: "Notification liste d'attente", +}; diff --git a/deployment-package/source_server/parametresDb.ts b/deployment-package/source_server/parametresDb.ts new file mode 100644 index 0000000..92df487 --- /dev/null +++ b/deployment-package/source_server/parametresDb.ts @@ -0,0 +1,134 @@ +import { eq, sql } from "drizzle-orm"; +import { getDb } from "./db"; +import { parametres, historiqueParametres } from "../drizzle/schema"; + +/** + * Récupérer les paramètres de l'application + * S'il n'y a pas de paramètres, en créer un avec les valeurs par défaut + */ +export async function getParametres() { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db.select().from(parametres).limit(1); + + if (result.length === 0) { + // Créer les paramètres par défaut + await db.insert(parametres).values({ + urlPublique: "https://formations.itinova.org", + }); + + const newResult = await db.select().from(parametres).limit(1); + return newResult[0]; + } + + return result[0]; +} + +/** + * Mettre à jour les paramètres de l'application + * Enregistre automatiquement l'historique des modifications + */ +export async function updateParametres( + updates: Partial<{ + urlPublique: string; + delaiExpirationQR: number; + dureeValiditeToken: number; + notificationsActives: boolean; + envoiAutomatiqueAttestations: boolean; + }>, + userId: number, + userName: string +) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const params = await getParametres(); + + // Enregistrer l'historique pour chaque champ modifié + for (const [key, value] of Object.entries(updates)) { + const oldValue = params[key as keyof typeof params]; + if (oldValue !== value) { + await db.insert(historiqueParametres).values({ + userId, + userName, + champModifie: key, + ancienneValeur: oldValue !== null && oldValue !== undefined ? String(oldValue) : null, + nouvelleValeur: value !== null && value !== undefined ? String(value) : null, + }); + } + } + + // Mettre à jour les paramètres + await db.update(parametres) + .set(updates) + .where(eq(parametres.id, params.id)); + + return getParametres(); +} + +/** + * Mettre à jour l'URL publique (fonction de compatibilité) + */ +export async function updateUrlPublique(urlPublique: string, userId?: number, userName?: string) { + return updateParametres( + { urlPublique }, + userId || 1, + userName || "Système" + ); +} + +/** + * Récupérer l'historique des modifications des paramètres + */ +export async function getHistoriqueParametres(limit: number = 50) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + return db.select() + .from(historiqueParametres) + .orderBy(sql`${historiqueParametres.dateModification} DESC`) + .limit(limit); +} + +/** + * Tester l'accessibilité d'une URL + */ +export async function testerUrl(url: string): Promise<{ accessible: boolean; message: string; statusCode?: number }> { + try { + // Vérifier le format de l'URL + const urlObj = new URL(url); + if (!['http:', 'https:'].includes(urlObj.protocol)) { + return { + accessible: false, + message: "L'URL doit commencer par http:// ou https://" + }; + } + + // Tenter une requête HEAD pour vérifier l'accessibilité + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(5000), // Timeout de 5 secondes + }); + + return { + accessible: response.ok, + message: response.ok + ? "L'URL est accessible" + : `L'URL a retourné une erreur HTTP ${response.status}`, + statusCode: response.status + }; + } catch (error) { + if (error instanceof TypeError && error.message.includes('Invalid URL')) { + return { + accessible: false, + message: "Format d'URL invalide" + }; + } + + return { + accessible: false, + message: `Erreur lors du test: ${error instanceof Error ? error.message : 'Erreur inconnue'}` + }; + } +} diff --git a/deployment-package/source_server/presenceDb.ts b/deployment-package/source_server/presenceDb.ts new file mode 100644 index 0000000..adb49f5 --- /dev/null +++ b/deployment-package/source_server/presenceDb.ts @@ -0,0 +1,149 @@ +import { eq, and } from "drizzle-orm"; +import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema"; +import { getDb } from "./db"; + +/** + * Valider la présence d'un apprenant (par QR code ou manuellement) + */ +export async function validerPresence(params: { + inscriptionId: number; + dateFormationId: number; + modeValidation: "qrcode" | "manuel"; + validateurId?: number; + commentaire?: string; +}) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier si la présence existe déjà + const presenceExistante = await db + .select() + .from(presences) + .where( + and( + eq(presences.inscriptionId, params.inscriptionId), + eq(presences.dateFormationId, params.dateFormationId) + ) + ) + .limit(1); + + if (presenceExistante.length > 0) { + throw new Error("Présence déjà validée pour cette date"); + } + + // Créer la présence + await db.insert(presences).values({ + inscriptionId: params.inscriptionId, + dateFormationId: params.dateFormationId, + heurePresence: new Date(), + modeValidation: params.modeValidation, + validateurId: params.validateurId, + commentaire: params.commentaire, + }); + + return { success: true }; +} + +/** + * Récupérer toutes les présences pour une séquence + */ +export async function getPresencesBySequence(sequenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db + .select({ + presenceId: presences.id, + inscriptionId: presences.inscriptionId, + dateFormationId: presences.dateFormationId, + heurePresence: presences.heurePresence, + modeValidation: presences.modeValidation, + validateurId: presences.validateurId, + commentaire: presences.commentaire, + apprenantId: apprenants.id, + apprenantNom: apprenants.nom, + apprenantPrenom: apprenants.prenom, + apprenantEmail: apprenants.email, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + }) + .from(presences) + .innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id)) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) + .where(eq(datesFormation.sequenceId, sequenceId)); + + return result; +} + +/** + * Récupérer les présences pour une inscription spécifique + */ +export async function getPresencesByInscription(inscriptionId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const result = await db + .select({ + presenceId: presences.id, + dateFormationId: presences.dateFormationId, + heurePresence: presences.heurePresence, + modeValidation: presences.modeValidation, + validateurId: presences.validateurId, + commentaire: presences.commentaire, + dateDebut: datesFormation.dateDebut, + dateFin: datesFormation.dateFin, + }) + .from(presences) + .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) + .where(eq(presences.inscriptionId, inscriptionId)); + + return result; +} + +/** + * Vérifier si toutes les présences sont validées pour une inscription + */ +export async function checkAllPresencesValidated(inscriptionId: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Récupérer l'inscription avec la séquence + const inscription = await db + .select({ + sequenceId: inscriptions.sequenceId, + }) + .from(inscriptions) + .where(eq(inscriptions.id, inscriptionId)) + .limit(1); + + if (inscription.length === 0) { + throw new Error("Inscription not found"); + } + + // Compter le nombre de dates de formation pour cette séquence + const datesCount = await db + .select({ count: datesFormation.id }) + .from(datesFormation) + .where(eq(datesFormation.sequenceId, inscription[0].sequenceId)); + + // Compter le nombre de présences validées pour cette inscription + const presencesCount = await db + .select({ count: presences.id }) + .from(presences) + .where(eq(presences.inscriptionId, inscriptionId)); + + return presencesCount.length === datesCount.length; +} + +/** + * Supprimer une présence + */ +export async function supprimerPresence(presenceId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(presences).where(eq(presences.id, presenceId)); + + return { success: true }; +} diff --git a/deployment-package/source_server/qrCodeGenerator.ts b/deployment-package/source_server/qrCodeGenerator.ts new file mode 100644 index 0000000..0d081a3 --- /dev/null +++ b/deployment-package/source_server/qrCodeGenerator.ts @@ -0,0 +1,63 @@ +import QRCode from "qrcode"; +import { randomBytes } from "crypto"; +import { getParametres } from "./parametresDb"; + +/** + * Générer un token unique pour le QR code + */ +export function generateQRToken(): string { + return randomBytes(32).toString("hex"); +} + +/** + * Générer un QR code en base64 à partir d'un token + * @param token Le token unique de la séquence + * @returns Une image QR code en base64 (data URL) + */ +export async function generateQRCodeDataURL(token: string): Promise { + // URL complète pour scanner le QR code + const parametres = await getParametres(); + const url = `${parametres.urlPublique}/emargement/${token}`; + + try { + const qrCodeDataURL = await QRCode.toDataURL(url, { + errorCorrectionLevel: "H", + type: "image/png", + width: 400, + margin: 2, + color: { + dark: "#000000", + light: "#FFFFFF", + }, + }); + + return qrCodeDataURL; + } catch (error) { + console.error("Erreur lors de la génération du QR code:", error); + throw new Error("Impossible de générer le QR code"); + } +} + +/** + * Générer un QR code en buffer PNG + * @param token Le token unique de la séquence + * @returns Un buffer PNG du QR code + */ +export async function generateQRCodeBuffer(token: string): Promise { + const parametres = await getParametres(); + const url = `${parametres.urlPublique}/emargement/${token}`; + + try { + const buffer = await QRCode.toBuffer(url, { + errorCorrectionLevel: "H", + type: "png", + width: 400, + margin: 2, + }); + + return buffer; + } catch (error) { + console.error("Erreur lors de la génération du QR code:", error); + throw new Error("Impossible de générer le QR code"); + } +} diff --git a/deployment-package/source_server/questionnaireDb.ts b/deployment-package/source_server/questionnaireDb.ts new file mode 100644 index 0000000..068a3d6 --- /dev/null +++ b/deployment-package/source_server/questionnaireDb.ts @@ -0,0 +1,250 @@ +import { eq, and, desc, sql } from "drizzle-orm"; +import { getDb } from "./db"; +import { + questionnaires, + questions, + reponsesQuestionnaires, + reponsesQuestions, + envoisQuestionnaires, + type Questionnaire, + type InsertQuestionnaire, + type Question, + type InsertQuestion, + type ReponseQuestionnaire, + type InsertReponseQuestionnaire, + type ReponseQuestion, + type InsertReponseQuestion, + type EnvoiQuestionnaire, + type InsertEnvoiQuestionnaire, +} from "../drizzle/schema"; + +// ========== QUESTIONNAIRES ========== + +export async function getAllQuestionnaires() { + const db = await getDb(); + if (!db) return []; + return await db.select().from(questionnaires).orderBy(desc(questionnaires.createdAt)); +} + +export async function getQuestionnaireById(id: number) { + const db = await getDb(); + if (!db) return null; + const result = await db.select().from(questionnaires).where(eq(questionnaires.id, id)).limit(1); + return result[0] || null; +} + +export async function createQuestionnaire(data: InsertQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(questionnaires).values(data); + return result[0].insertId; +} + +export async function updateQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(questionnaires).set(data).where(eq(questionnaires.id, id)); +} + +export async function deleteQuestionnaire(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + // Supprimer d'abord les questions associées + await db.delete(questions).where(eq(questions.questionnaireId, id)); + // Puis le questionnaire + await db.delete(questionnaires).where(eq(questionnaires.id, id)); +} + +// ========== QUESTIONS ========== + +export async function getQuestionsByQuestionnaireId(questionnaireId: number) { + const db = await getDb(); + if (!db) return []; + return await db + .select() + .from(questions) + .where(eq(questions.questionnaireId, questionnaireId)) + .orderBy(questions.ordre); +} + +export async function createQuestion(data: InsertQuestion) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(questions).values(data); + return result[0].insertId; +} + +export async function updateQuestion(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(questions).set(data).where(eq(questions.id, id)); +} + +export async function deleteQuestion(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.delete(questions).where(eq(questions.id, id)); +} + +// ========== RÉPONSES QUESTIONNAIRES ========== + +export async function createReponseQuestionnaire(data: InsertReponseQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(reponsesQuestionnaires).values(data); + return result[0].insertId; +} + +export async function getReponseQuestionnaire(questionnaireId: number, apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return null; + const result = await db + .select() + .from(reponsesQuestionnaires) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.apprenantId, apprenantId), + eq(reponsesQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + return result[0] || null; +} + +export async function updateReponseQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(reponsesQuestionnaires).set(data).where(eq(reponsesQuestionnaires.id, id)); +} + +// ========== RÉPONSES QUESTIONS ========== + +export async function createReponseQuestion(data: InsertReponseQuestion) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(reponsesQuestions).values(data); + return result[0].insertId; +} + +export async function getReponsesByReponseQuestionnaireId(reponseQuestionnaireId: number) { + const db = await getDb(); + if (!db) return []; + return await db + .select() + .from(reponsesQuestions) + .where(eq(reponsesQuestions.reponseQuestionnaireId, reponseQuestionnaireId)); +} + +// ========== ENVOIS QUESTIONNAIRES ========== + +export async function createEnvoiQuestionnaire(data: InsertEnvoiQuestionnaire) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(envoisQuestionnaires).values(data); + return result[0].insertId; +} + +export async function getEnvoiByToken(token: string) { + const db = await getDb(); + if (!db) return null; + const result = await db + .select() + .from(envoisQuestionnaires) + .where(eq(envoisQuestionnaires.token, token)) + .limit(1); + return result[0] || null; +} + +export async function updateEnvoiQuestionnaire(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(envoisQuestionnaires).set(data).where(eq(envoisQuestionnaires.id, id)); +} + +export async function checkEnvoiExists(questionnaireId: number, apprenantId: number, sequenceId: number) { + const db = await getDb(); + if (!db) return false; + const result = await db + .select() + .from(envoisQuestionnaires) + .where( + and( + eq(envoisQuestionnaires.questionnaireId, questionnaireId), + eq(envoisQuestionnaires.apprenantId, apprenantId), + eq(envoisQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + return result.length > 0; +} + +// ========== STATISTIQUES ========== + +/** + * Récupère les statistiques d'un questionnaire + */ +export async function getQuestionnaireStats(questionnaireId: number) { + const db = await getDb(); + if (!db) return null; + + // Nombre total d'envois + const envoisResult = await db + .select({ count: sql`count(*)` }) + .from(envoisQuestionnaires) + .where(eq(envoisQuestionnaires.questionnaireId, questionnaireId)); + const totalEnvois = envoisResult[0]?.count || 0; + + // Nombre de réponses + const reponsesResult = await db + .select({ count: sql`count(*)` }) + .from(reponsesQuestionnaires) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.complete, true) + ) + ); + const totalReponses = reponsesResult[0]?.count || 0; + + // Taux de réponse + const tauxReponse = totalEnvois > 0 ? (totalReponses / totalEnvois) * 100 : 0; + + return { + totalEnvois, + totalReponses, + tauxReponse: Math.round(tauxReponse * 10) / 10, // 1 décimale + }; +} + +/** + * Récupère les réponses détaillées d'un questionnaire pour analyse + */ +export async function getReponsesDetailleesQuestionnaire(questionnaireId: number) { + const db = await getDb(); + if (!db) return []; + + const result = await db + .select({ + reponseQuestionnaireId: reponsesQuestionnaires.id, + apprenantId: reponsesQuestionnaires.apprenantId, + sequenceId: reponsesQuestionnaires.sequenceId, + dateReponse: reponsesQuestionnaires.dateReponse, + questionId: reponsesQuestions.questionId, + reponseNumerique: reponsesQuestions.reponseNumerique, + reponseTexte: reponsesQuestions.reponseTexte, + }) + .from(reponsesQuestionnaires) + .leftJoin( + reponsesQuestions, + eq(reponsesQuestions.reponseQuestionnaireId, reponsesQuestionnaires.id) + ) + .where( + and( + eq(reponsesQuestionnaires.questionnaireId, questionnaireId), + eq(reponsesQuestionnaires.complete, true) + ) + ); + + return result; +} diff --git a/deployment-package/source_server/questionnaireExport.ts b/deployment-package/source_server/questionnaireExport.ts new file mode 100644 index 0000000..5257898 --- /dev/null +++ b/deployment-package/source_server/questionnaireExport.ts @@ -0,0 +1,174 @@ +import ExcelJS from 'exceljs'; +import { jsPDF } from 'jspdf'; +import { getQuestionnaireById, getQuestionsByQuestionnaireId, getQuestionnaireStats, getReponsesDetailleesQuestionnaire } from './questionnaireDb'; +import { getDb } from './db'; +import { sql } from 'drizzle-orm'; + +/** + * Génère un fichier Excel avec les statistiques du questionnaire + */ +export async function exportQuestionnaireToExcel(questionnaireId: number): Promise { + const questionnaire = await getQuestionnaireById(questionnaireId); + const stats = await getQuestionnaireStats(questionnaireId); + const questionsData = await getQuestionsByQuestionnaireId(questionnaireId); + const reponsesData = await getReponsesDetailleesQuestionnaire(questionnaireId); + + if (!questionnaire || !stats) { + throw new Error('Questionnaire introuvable'); + } + + const workbook = new ExcelJS.Workbook(); + + // Feuille 1: Statistiques globales + const statsSheet = workbook.addWorksheet('Statistiques'); + + statsSheet.addRow(['Questionnaire', questionnaire.titre]); + statsSheet.addRow(['Type', questionnaire.type]); + statsSheet.addRow(['']); + statsSheet.addRow(['Nombre d\'envois', stats.totalEnvois]); + statsSheet.addRow(['Nombre de réponses', stats.totalReponses]); + statsSheet.addRow(['Taux de réponse', `${stats.tauxReponse.toFixed(1)}%`]); + statsSheet.addRow(['']); + + // Style pour l'en-tête + statsSheet.getColumn(1).width = 30; + statsSheet.getColumn(2).width = 40; + statsSheet.getRow(1).font = { bold: true, size: 14 }; + + // Feuille 2: Statistiques par question + const questionsSheet = workbook.addWorksheet('Par question'); + + questionsSheet.addRow(['Question', 'Type', 'Ordre']); + questionsSheet.getRow(1).font = { bold: true }; + questionsSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + + questionsData.forEach((q: any) => { + questionsSheet.addRow([ + q.texte, + q.typeQuestion, + q.ordre + ]); + }); + + questionsSheet.getColumn(1).width = 60; + questionsSheet.getColumn(2).width = 20; + questionsSheet.getColumn(3).width = 10; + + // Feuille 3: Réponses brutes + const reponsesSheet = workbook.addWorksheet('Réponses brutes'); + + reponsesSheet.addRow(['Date réponse', 'Apprenant ID', 'Séquence ID', 'Question ID', 'Réponse numérique', 'Réponse texte']); + reponsesSheet.getRow(1).font = { bold: true }; + reponsesSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + + reponsesData.forEach((r: any) => { + reponsesSheet.addRow([ + r.dateReponse ? new Date(r.dateReponse).toLocaleDateString('fr-FR') : '', + r.apprenantId, + r.sequenceId, + r.questionId, + r.reponseNumerique || '', + r.reponseTexte || '' + ]); + }); + + reponsesSheet.getColumn(1).width = 15; + reponsesSheet.getColumn(2).width = 15; + reponsesSheet.getColumn(3).width = 15; + reponsesSheet.getColumn(4).width = 15; + reponsesSheet.getColumn(5).width = 20; + reponsesSheet.getColumn(6).width = 50; + + // Générer le buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +/** + * Génère un fichier PDF avec les statistiques du questionnaire + */ +export async function exportQuestionnaireToPDF(questionnaireId: number): Promise { + const questionnaire = await getQuestionnaireById(questionnaireId); + const stats = await getQuestionnaireStats(questionnaireId); + const questionsData = await getQuestionsByQuestionnaireId(questionnaireId); + + if (!questionnaire || !stats) { + throw new Error('Questionnaire introuvable'); + } + + const doc = new jsPDF(); + let yPos = 20; + + // Titre + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text(questionnaire.titre, 20, yPos); + yPos += 10; + + // Type + doc.setFontSize(12); + doc.setFont('helvetica', 'normal'); + doc.text(`Type: ${questionnaire.type}`, 20, yPos); + yPos += 15; + + // Statistiques globales + doc.setFont('helvetica', 'bold'); + doc.text('Statistiques globales', 20, yPos); + yPos += 8; + + doc.setFont('helvetica', 'normal'); + doc.text(`Nombre d'envois: ${stats.totalEnvois}`, 20, yPos); + yPos += 6; + doc.text(`Nombre de réponses: ${stats.totalReponses}`, 20, yPos); + yPos += 6; + doc.text(`Taux de réponse: ${stats.tauxReponse.toFixed(1)}%`, 20, yPos); + yPos += 15; + + // Liste des questions + doc.setFont('helvetica', 'bold'); + doc.text('Questions du questionnaire', 20, yPos); + yPos += 10; + + questionsData.forEach((q: any, index: number) => { + // Vérifier si on doit ajouter une nouvelle page + if (yPos > 250) { + doc.addPage(); + yPos = 20; + } + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + const questionText = `${index + 1}. ${q.texte}`; + const lines = doc.splitTextToSize(questionText, 170); + doc.text(lines, 20, yPos); + yPos += lines.length * 6; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.text(`Type: ${q.typeQuestion}`, 25, yPos); + yPos += 8; + }); + + // Pied de page + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setFont('helvetica', 'normal'); + doc.text( + `Page ${i} sur ${pageCount} - Généré le ${new Date().toLocaleDateString('fr-FR')}`, + 20, + 290 + ); + } + + return Buffer.from(doc.output('arraybuffer')); +} diff --git a/deployment-package/source_server/questionnaireScheduler.ts b/deployment-package/source_server/questionnaireScheduler.ts new file mode 100644 index 0000000..f476d6f --- /dev/null +++ b/deployment-package/source_server/questionnaireScheduler.ts @@ -0,0 +1,248 @@ +import { getDb } from "./db"; +import { eq, and, lte, sql } from "drizzle-orm"; +import { + questionnaires, + sequences, + inscriptions, + apprenants, + datesFormation, + envoisQuestionnaires, +} from "../drizzle/schema"; +import { + getAllQuestionnaires, + checkEnvoiExists, + createEnvoiQuestionnaire, +} from "./questionnaireDb"; +import { sendEmail } from "./_core/emailSender"; +import crypto from "crypto"; + +/** + * Génère un token unique pour accéder au questionnaire + */ +function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * Envoie un questionnaire à un apprenant pour une séquence donnée + */ +async function envoyerQuestionnaire( + questionnaireId: number, + apprenantId: number, + sequenceId: number, + apprenantEmail: string, + apprenantNom: string, + apprenantPrenom: string, + questionnairenom: string, + sequenceNom: string +) { + // Vérifier si le questionnaire n'a pas déjà été envoyé + const dejaEnvoye = await checkEnvoiExists(questionnaireId, apprenantId, sequenceId); + if (dejaEnvoye) { + console.log( + `[Questionnaires] Questionnaire ${questionnaireId} déjà envoyé à l'apprenant ${apprenantId} pour la séquence ${sequenceId}` + ); + return false; + } + + // Générer un token unique + const token = generateToken(); + + // Créer l'envoi + await createEnvoiQuestionnaire({ + questionnaireId, + apprenantId, + sequenceId, + token, + dateEnvoi: new Date(), + dateReponse: null, + }); + + // Envoyer l'email + const lienQuestionnaire = `${process.env.VITE_FRONTEND_URL || "http://localhost:3000"}/questionnaire/${token}`; + + const emailSent = await sendEmail({ + to: apprenantEmail, + subject: `Questionnaire : ${questionnairenom}`, + html: ` +

    Bonjour ${apprenantPrenom} ${apprenantNom},

    + +

    Nous vous remercions d'avoir participé à la formation ${sequenceNom}.

    + +

    Afin d'améliorer continuellement la qualité de nos formations, nous vous invitons à répondre à ce questionnaire :

    + +

    + + Répondre au questionnaire + +

    + +

    Ce questionnaire ne vous prendra que quelques minutes.

    + +

    Merci pour votre participation !

    + +

    + Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
    + ${lienQuestionnaire} +

    + `, + }); + + if (emailSent) { + console.log( + `[Questionnaires] Questionnaire ${questionnaireId} envoyé à ${apprenantEmail} pour la séquence ${sequenceId}` + ); + return true; + } else { + console.error( + `[Questionnaires] Échec de l'envoi du questionnaire ${questionnaireId} à ${apprenantEmail}` + ); + return false; + } +} + +/** + * Traite les envois automatiques de questionnaires post-formation + */ +export async function processEnvoisAutomatiques() { + console.log("[Questionnaires] Démarrage du traitement des envois automatiques..."); + + const db = await getDb(); + if (!db) { + console.log("[Questionnaires] Base de données non disponible"); + return { success: false, message: "Base de données non disponible" }; + } + + try { + // Récupérer tous les questionnaires actifs avec envoi automatique + const questionnairesActifs = await getAllQuestionnaires(); + const questionnairesAEnvoyer = questionnairesActifs.filter( + (q) => q.actif && q.envoiAutomatique + ); + + if (questionnairesAEnvoyer.length === 0) { + console.log("[Questionnaires] Aucun questionnaire avec envoi automatique configuré"); + return { success: true, message: "Aucun questionnaire à envoyer", count: 0 }; + } + + let totalEnvoyes = 0; + + for (const questionnaire of questionnairesAEnvoyer) { + console.log( + `[Questionnaires] Traitement du questionnaire "${questionnaire.titre}" (délai: J+${questionnaire.delaiEnvoiJours})` + ); + + // Calculer la date cible (aujourd'hui - délai) + const dateCible = new Date(); + dateCible.setDate(dateCible.getDate() - questionnaire.delaiEnvoiJours); + dateCible.setHours(0, 0, 0, 0); + + // Récupérer les séquences terminées à la date cible + const sequencesTerminees = await db + .select({ + sequenceId: sequences.id, + sequenceNom: sequences.nom, + formationId: sequences.formationId, + }) + .from(sequences) + .innerJoin(datesFormation, eq(datesFormation.sequenceId, sequences.id)) + .where(lte(datesFormation.dateFin, dateCible)) + .groupBy(sequences.id); + + console.log( + `[Questionnaires] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s) pour le ${dateCible.toLocaleDateString("fr-FR")}` + ); + + // Pour chaque séquence terminée + for (const seq of sequencesTerminees) { + // Vérifier si le questionnaire est lié à une formation spécifique + if (questionnaire.formationId && questionnaire.formationId !== seq.formationId) { + continue; + } + + // Récupérer les apprenants inscrits et validés + const apprenantsInscrits = await db + .select({ + apprenantId: apprenants.id, + apprenantEmail: apprenants.email, + apprenantNom: apprenants.nom, + apprenantPrenom: apprenants.prenom, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(apprenants.id, inscriptions.apprenantId)) + .where( + and( + eq(inscriptions.sequenceId, seq.sequenceId), + eq(inscriptions.statut, "confirmee") + ) + ); + + console.log( + `[Questionnaires] ${apprenantsInscrits.length} apprenant(s) inscrit(s) à la séquence ${seq.sequenceId}` + ); + + // Envoyer le questionnaire à chaque apprenant + for (const apprenant of apprenantsInscrits) { + if (!apprenant.apprenantEmail) { + console.log( + `[Questionnaires] Apprenant ${apprenant.apprenantId} sans email, envoi ignoré` + ); + continue; + } + + const envoye = await envoyerQuestionnaire( + questionnaire.id, + apprenant.apprenantId, + seq.sequenceId, + apprenant.apprenantEmail, + apprenant.apprenantNom, + apprenant.apprenantPrenom, + questionnaire.titre, + seq.sequenceNom + ); + + if (envoye) { + totalEnvoyes++; + } + } + } + } + + console.log(`[Questionnaires] Traitement terminé - ${totalEnvoyes} questionnaire(s) envoyé(s)`); + return { + success: true, + message: `${totalEnvoyes} questionnaire(s) envoyé(s)`, + count: totalEnvoyes, + }; + } catch (error: any) { + console.error("[Questionnaires] Erreur lors du traitement :", error); + return { success: false, message: error.message, count: 0 }; + } +} + +/** + * Initialise le scheduler pour les envois automatiques de questionnaires + * Exécute le traitement tous les jours à 9h00 + */ +export function initQuestionnaireScheduler() { + console.log("[Questionnaires] Initialisation du scheduler d'envoi automatique"); + + // Exécuter immédiatement au démarrage + processEnvoisAutomatiques(); + + // Puis exécuter tous les jours à 9h00 + const interval = setInterval( + () => { + const now = new Date(); + if (now.getHours() === 9 && now.getMinutes() === 0) { + processEnvoisAutomatiques(); + } + }, + 60 * 1000 // Vérifier toutes les minutes + ); + + console.log("[Questionnaires] Scheduler initialisé - vérification quotidienne à 9h00"); + + return interval; +} diff --git a/deployment-package/source_server/questionnaireSuiviDb.ts b/deployment-package/source_server/questionnaireSuiviDb.ts new file mode 100644 index 0000000..8dcd1ba --- /dev/null +++ b/deployment-package/source_server/questionnaireSuiviDb.ts @@ -0,0 +1,164 @@ +import { eq, sql, and, gte, lte } from "drizzle-orm"; +import { getDb } from "./db"; +import { + questionnaires, + envoisQuestionnaires, + reponsesQuestionnaires, + reponsesQuestions, + questions, + sequences, + formations, + formateurs +} from "../drizzle/schema"; + +/** + * Statistiques de taux de réponse par formation + */ +export async function getStatsByFormation() { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const results = await db + .select({ + formationId: formations.id, + formationNom: formations.nom, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`, + }) + .from(envoisQuestionnaires) + .innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id)) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .groupBy(formations.id, formations.nom); + + return results.map(r => ({ + formationId: r.formationId, + formationNom: r.formationNom, + totalEnvois: Number(r.totalEnvois), + totalReponses: Number(r.totalReponses), + tauxReponse: Number(r.totalEnvois) > 0 + ? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100) + : 0 + })); +} + +/** + * Évolution temporelle des réponses (par mois) + */ +export async function getEvolutionTemporelle(startDate?: Date, endDate?: Date) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const conditions = []; + if (startDate) { + conditions.push(gte(envoisQuestionnaires.dateEnvoi, startDate)); + } + if (endDate) { + conditions.push(lte(envoisQuestionnaires.dateEnvoi, endDate)); + } + + const results = await db + .select({ + mois: sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`, + }) + .from(envoisQuestionnaires) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .groupBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`) + .orderBy(sql`DATE_FORMAT(${envoisQuestionnaires.dateEnvoi}, '%Y-%m')`); + + return results.map(r => ({ + mois: r.mois, + totalEnvois: Number(r.totalEnvois), + totalReponses: Number(r.totalReponses), + tauxReponse: Number(r.totalEnvois) > 0 + ? Math.round((Number(r.totalReponses) / Number(r.totalEnvois)) * 100) + : 0 + })); +} + +/** + * Comparaison par formateur + */ +export async function getStatsByFormateur() { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Première requête : statistiques d'envois et réponses par formateur + const statsEnvois = await db + .select({ + formateurId: formateurs.id, + formateurNom: formateurs.nom, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`, + }) + .from(envoisQuestionnaires) + .innerJoin(sequences, eq(envoisQuestionnaires.sequenceId, sequences.id)) + .innerJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .groupBy(formateurs.id, formateurs.nom); + + // Pour chaque formateur, calculer la moyenne de satisfaction + const results = await Promise.all(statsEnvois.map(async (stat) => { + // Calculer la moyenne des réponses de type échelle pour ce formateur + const satisfactionResult = await db + .select({ + moyenne: sql`AVG(CAST(${reponsesQuestions.reponseNumerique} AS DECIMAL(10,2)))`, + }) + .from(reponsesQuestionnaires) + .innerJoin(sequences, eq(reponsesQuestionnaires.sequenceId, sequences.id)) + .innerJoin(reponsesQuestions, eq(reponsesQuestionnaires.id, reponsesQuestions.reponseQuestionnaireId)) + .innerJoin(questions, eq(reponsesQuestions.questionId, questions.id)) + .where( + and( + eq(sequences.formateurId, stat.formateurId), + eq(reponsesQuestionnaires.complete, true), + eq(questions.typeQuestion, 'echelle') + ) + ); + + const moyenneSatisfaction = satisfactionResult[0]?.moyenne + ? Number(satisfactionResult[0].moyenne).toFixed(1) + : null; + + return { + formateurId: stat.formateurId, + formateurNom: stat.formateurNom || "Non spécifié", + totalEnvois: Number(stat.totalEnvois), + totalReponses: Number(stat.totalReponses), + tauxReponse: Number(stat.totalEnvois) > 0 + ? Math.round((Number(stat.totalReponses) / Number(stat.totalEnvois)) * 100) + : 0, + moyenneSatisfaction + }; + })); + + return results; +} + +/** + * Statistiques globales pour le tableau de bord + */ +export async function getStatsGlobales() { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const results = await db + .select({ + totalQuestionnaires: sql`COUNT(DISTINCT ${questionnaires.id})`, + totalEnvois: sql`COUNT(DISTINCT ${envoisQuestionnaires.id})`, + totalReponses: sql`SUM(CASE WHEN ${envoisQuestionnaires.dateReponse} IS NOT NULL THEN 1 ELSE 0 END)`, + }) + .from(questionnaires) + .leftJoin(envoisQuestionnaires, eq(questionnaires.id, envoisQuestionnaires.questionnaireId)); + + const stats = results[0]; + const totalEnvois = Number(stats.totalEnvois) || 0; + const totalReponses = Number(stats.totalReponses) || 0; + + return { + totalQuestionnaires: Number(stats.totalQuestionnaires) || 0, + totalEnvois, + totalReponses, + tauxReponseGlobal: totalEnvois > 0 ? Number(((totalReponses / totalEnvois) * 100).toFixed(1)) : 0 + }; +} diff --git a/deployment-package/source_server/rappelDb.ts b/deployment-package/source_server/rappelDb.ts new file mode 100644 index 0000000..1f7da15 --- /dev/null +++ b/deployment-package/source_server/rappelDb.ts @@ -0,0 +1,328 @@ +import { getDb } from "./db"; +import { logsRappels, InsertLogRappel, LogRappel } from "../drizzle/schema"; +import { eq, and } from "drizzle-orm"; + +/** + * Enregistre l'envoi d'un rappel dans les logs + */ +export async function logRappelEnvoye(data: { + rappelId: number; + sequenceId: number; + apprenantId: number; + emailDestinataire: string; + typeRappel: string; + statut: "success" | "failed"; + messageErreur?: string; + dateSequence: Date; +}): Promise { + const db = await getDb(); + if (!db) { + console.warn("[LogsRappels] Base de données non disponible"); + return; + } + + try { + await db.insert(logsRappels).values({ + rappelId: data.rappelId, + sequenceId: data.sequenceId, + apprenantId: data.apprenantId, + emailDestinataire: data.emailDestinataire, + typeRappel: data.typeRappel, + dateEnvoi: new Date(), + statut: data.statut, + messageErreur: data.messageErreur || null, + dateSequence: data.dateSequence, + } as InsertLogRappel); + } catch (error) { + console.error("[LogsRappels] Erreur lors de l'enregistrement du log:", error); + } +} + +/** + * Vérifie si un rappel a déjà été envoyé à un apprenant pour une séquence donnée + */ +export async function rappelDejaEnvoye( + rappelId: number, + sequenceId: number, + apprenantId: number +): Promise { + const db = await getDb(); + if (!db) { + console.warn("[LogsRappels] Base de données non disponible"); + return false; + } + + try { + const logs = await db + .select() + .from(logsRappels) + .where( + and( + eq(logsRappels.rappelId, rappelId), + eq(logsRappels.sequenceId, sequenceId), + eq(logsRappels.apprenantId, apprenantId), + eq(logsRappels.statut, "success") + ) + ) + .limit(1); + + return logs.length > 0; + } catch (error) { + console.error("[LogsRappels] Erreur lors de la vérification des doublons:", error); + return false; + } +} + +/** + * Récupère tous les logs de rappels pour une séquence donnée + */ +export async function getLogsRappelsBySequence(sequenceId: number): Promise { + const db = await getDb(); + if (!db) return []; + + try { + return await db + .select() + .from(logsRappels) + .where(eq(logsRappels.sequenceId, sequenceId)) + .orderBy(logsRappels.dateEnvoi); + } catch (error) { + console.error("[LogsRappels] Erreur lors de la récupération des logs:", error); + return []; + } +} + +/** + * Récupère tous les logs de rappels échoués récents (dernières 24h) + */ +export async function getLogsRappelsEchoues(): Promise { + const db = await getDb(); + if (!db) return []; + + try { + const hier = new Date(); + hier.setDate(hier.getDate() - 1); + + return await db + .select() + .from(logsRappels) + .where( + and( + eq(logsRappels.statut, "failed"), + // Note: Drizzle ne supporte pas directement les comparaisons de dates + // On récupère tous les échecs et on filtre en JS + ) + ) + .orderBy(logsRappels.dateEnvoi); + } catch (error) { + console.error("[LogsRappels] Erreur lors de la récupération des échecs:", error); + return []; + } +} + +/** + * Compte le nombre de rappels envoyés avec succès pour une séquence + */ +export async function countRappelsEnvoyesSequence(sequenceId: number): Promise { + const db = await getDb(); + if (!db) return 0; + + try { + const logs = await db + .select() + .from(logsRappels) + .where( + and( + eq(logsRappels.sequenceId, sequenceId), + eq(logsRappels.statut, "success") + ) + ); + + return logs.length; + } catch (error) { + console.error("[LogsRappels] Erreur lors du comptage:", error); + return 0; + } +} + +/** + * Récupère les logs de rappels avec filtres + */ +export async function getLogsRappelsWithFilters(filters: { + dateDebut?: Date; + dateFin?: Date; + sequenceId?: number; + statut?: "success" | "failed"; + email?: string; + limit?: number; + offset?: number; +}): Promise { + const db = await getDb(); + if (!db) return []; + + try { + let query = db.select().from(logsRappels); + + const conditions = []; + if (filters.sequenceId) { + conditions.push(eq(logsRappels.sequenceId, filters.sequenceId)); + } + if (filters.statut) { + conditions.push(eq(logsRappels.statut, filters.statut)); + } + if (filters.email) { + // Note: Drizzle ne supporte pas LIKE directement, on filtre en JS + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + let logs = await query.orderBy(logsRappels.dateEnvoi).limit(filters.limit || 100).offset(filters.offset || 0); + + // Filtrer par email si nécessaire + if (filters.email) { + logs = logs.filter(log => log.emailDestinataire.toLowerCase().includes(filters.email!.toLowerCase())); + } + + // Filtrer par date si nécessaire + if (filters.dateDebut) { + logs = logs.filter(log => new Date(log.dateEnvoi) >= filters.dateDebut!); + } + if (filters.dateFin) { + logs = logs.filter(log => new Date(log.dateEnvoi) <= filters.dateFin!); + } + + return logs; + } catch (error) { + console.error("[LogsRappels] Erreur lors de la récupération avec filtres:", error); + return []; + } +} + +/** + * Calcule les statistiques des rappels + */ +export async function getStatsRappels(): Promise<{ + totalEnvoyes: number; + totalSucces: number; + totalEchecs: number; + tauxSucces: number; + emailsProblematiques: Array<{ email: string; nbEchecs: number }>; +}> { + const db = await getDb(); + if (!db) { + return { + totalEnvoyes: 0, + totalSucces: 0, + totalEchecs: 0, + tauxSucces: 0, + emailsProblematiques: [], + }; + } + + try { + const logs = await db.select().from(logsRappels); + + const totalEnvoyes = logs.length; + const totalSucces = logs.filter(l => l.statut === "success").length; + const totalEchecs = logs.filter(l => l.statut === "failed").length; + const tauxSucces = totalEnvoyes > 0 ? (totalSucces / totalEnvoyes) * 100 : 0; + + // Compter les échecs par email + const echecsParEmail = new Map(); + logs.filter(l => l.statut === "failed").forEach(log => { + const count = echecsParEmail.get(log.emailDestinataire) || 0; + echecsParEmail.set(log.emailDestinataire, count + 1); + }); + + const emailsProblematiques = Array.from(echecsParEmail.entries()) + .map(([email, nbEchecs]) => ({ email, nbEchecs })) + .sort((a, b) => b.nbEchecs - a.nbEchecs) + .slice(0, 10); // Top 10 + + return { + totalEnvoyes, + totalSucces, + totalEchecs, + tauxSucces, + emailsProblematiques, + }; + } catch (error) { + console.error("[LogsRappels] Erreur lors du calcul des statistiques:", error); + return { + totalEnvoyes: 0, + totalSucces: 0, + totalEchecs: 0, + tauxSucces: 0, + emailsProblematiques: [], + }; + } +} + +/** + * Récupère l'évolution des envois par jour + */ +export async function getEvolutionEnvois(): Promise> { + const db = await getDb(); + if (!db) return []; + + try { + const logs = await db.select().from(logsRappels).orderBy(logsRappels.dateEnvoi); + + // Grouper par jour + const parJour = new Map(); + + logs.forEach(log => { + const dateStr = new Date(log.dateEnvoi).toISOString().split('T')[0]; + const stats = parJour.get(dateStr) || { nbEnvoyes: 0, nbSucces: 0, nbEchecs: 0 }; + stats.nbEnvoyes++; + if (log.statut === "success") stats.nbSucces++; + else stats.nbEchecs++; + parJour.set(dateStr, stats); + }); + + return Array.from(parJour.entries()) + .map(([date, stats]) => ({ date, ...stats })) + .sort((a, b) => a.date.localeCompare(b.date)); + } catch (error) { + console.error("[LogsRappels] Erreur lors de la récupération de l'évolution:", error); + return []; + } +} + +/** + * Récupère les logs échoués à réessayer + */ +export async function getLogsAReessayer(): Promise { + const db = await getDb(); + if (!db) return []; + + try { + const maintenant = new Date(); + const logs = await db + .select() + .from(logsRappels) + .where( + and( + eq(logsRappels.statut, "failed"), + // Limiter à 3 tentatives maximum + ) + ); + + // Filtrer en JS pour les conditions complexes + return logs.filter(log => { + if (log.nbTentatives >= 3) return false; + if (!log.prochainEssai) return true; // Premier essai + return new Date(log.prochainEssai) <= maintenant; + }); + } catch (error) { + console.error("[LogsRappels] Erreur lors de la récupération des logs à réessayer:", error); + return []; + } +} diff --git a/deployment-package/source_server/rappelRetry.ts b/deployment-package/source_server/rappelRetry.ts new file mode 100644 index 0000000..63cd7da --- /dev/null +++ b/deployment-package/source_server/rappelRetry.ts @@ -0,0 +1,226 @@ +import { getDb } from "./db"; +import { logsRappels, sequences, inscriptions, apprenants, formations, datesFormation, rappels, formateurs } from "../drizzle/schema"; +import { eq, and } from "drizzle-orm"; +import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService"; +import { getLogsAReessayer } from "./rappelDb"; +import { notifyOwner } from "./_core/notification"; + +/** + * Calcule le délai avant le prochain essai selon le nombre de tentatives + * Délai exponentiel : 1h, 4h, 24h + */ +function calculerProchainEssai(nbTentatives: number): Date { + const maintenant = new Date(); + let delaiHeures = 1; // 1 heure par défaut + + if (nbTentatives === 1) { + delaiHeures = 1; // 1ère tentative échouée → réessayer dans 1h + } else if (nbTentatives === 2) { + delaiHeures = 4; // 2ème tentative échouée → réessayer dans 4h + } else { + delaiHeures = 24; // 3ème tentative échouée → réessayer dans 24h (mais on arrête à 3) + } + + maintenant.setHours(maintenant.getHours() + delaiHeures); + return maintenant; +} + +/** + * Réessaye d'envoyer les rappels échoués + */ +export async function processRappelRetry() { + console.log("[Rappels Retry] Démarrage du processus de réessai"); + + const db = await getDb(); + if (!db) { + console.error("[Rappels Retry] Base de données non disponible"); + return; + } + + try { + // Récupérer les logs à réessayer + const logsAReessayer = await getLogsAReessayer(); + + if (logsAReessayer.length === 0) { + console.log("[Rappels Retry] Aucun rappel à réessayer"); + return; + } + + console.log(`[Rappels Retry] ${logsAReessayer.length} rappel(s) à réessayer`); + + let nbSucces = 0; + let nbEchecs = 0; + + for (const log of logsAReessayer) { + try { + // Récupérer les informations de la séquence + const sequence = await db + .select() + .from(sequences) + .where(eq(sequences.id, log.sequenceId)) + .limit(1); + + if (sequence.length === 0) { + console.warn(`[Rappels Retry] Séquence ${log.sequenceId} introuvable`); + continue; + } + + const seq = sequence[0]; + + // Récupérer la formation + const formation = await db + .select() + .from(formations) + .where(eq(formations.id, seq.formationId)) + .limit(1); + + if (formation.length === 0) { + console.warn(`[Rappels Retry] Formation ${seq.formationId} introuvable`); + continue; + } + + // Récupérer l'apprenant + const apprenant = await db + .select() + .from(apprenants) + .where(eq(apprenants.id, log.apprenantId)) + .limit(1); + + if (apprenant.length === 0 || !apprenant[0].email) { + console.warn(`[Rappels Retry] Apprenant ${log.apprenantId} introuvable ou sans email`); + continue; + } + + // Récupérer les dates de la séquence + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, seq.id)) + .orderBy(datesFormation.ordre); + + // Récupérer le formateur si disponible + let formateurNom = ""; + if (seq.formateurId) { + const formateur = await db + .select() + .from(formateurs) + .where(eq(formateurs.id, seq.formateurId)) + .limit(1); + if (formateur.length > 0) { + formateurNom = formateur[0].nom; + } + } + + // Réessayer l'envoi + try { + if (log.typeRappel === "rappel") { + await sendRappelJ7Email({ + apprenantEmail: apprenant[0].email, + apprenantPrenom: apprenant[0].prenom, + apprenantNom: apprenant[0].nom, + apprenantFonction: apprenant[0].fonction, + formationNom: formation[0].nom, + sequenceNom: seq.nom, + dates: dates.map(d => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: seq.lieu || "", + }); + } else if (log.typeRappel === "rappelJ1") { + await sendRappelJ1Email({ + apprenantEmail: apprenant[0].email, + apprenantPrenom: apprenant[0].prenom, + apprenantNom: apprenant[0].nom, + apprenantFonction: apprenant[0].fonction, + formationNom: formation[0].nom, + sequenceNom: seq.nom, + dates: dates.map(d => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: seq.lieu || "", + }); + } + + // Succès : mettre à jour le log + await db + .update(logsRappels) + .set({ + statut: "success", + nbTentatives: log.nbTentatives + 1, + prochainEssai: null, + }) + .where(eq(logsRappels.id, log.id)); + + console.log(`[Rappels Retry] Succès pour ${apprenant[0].email} après ${log.nbTentatives + 1} tentative(s)`); + nbSucces++; + } catch (emailError: any) { + const messageErreur = emailError?.message || String(emailError); + const nouvelleTentative = log.nbTentatives + 1; + + if (nouvelleTentative >= 3) { + // Abandon après 3 tentatives + await db + .update(logsRappels) + .set({ + statut: "failed", + nbTentatives: nouvelleTentative, + messageErreur: `Abandon après 3 tentatives: ${messageErreur}`, + prochainEssai: null, + }) + .where(eq(logsRappels.id, log.id)); + + console.error(`[Rappels Retry] Abandon pour ${apprenant[0].email} après 3 tentatives`); + + // Notifier l'admin + await notifyOwner({ + title: `❌ Abandon d'envoi de rappel`, + content: `Le rappel pour ${apprenant[0].email} (${seq.nom}) a échoué 3 fois et a été abandonné.\\n\\nDernière erreur: ${messageErreur}`, + }); + } else { + // Planifier un nouvel essai + const prochainEssai = calculerProchainEssai(nouvelleTentative); + await db + .update(logsRappels) + .set({ + nbTentatives: nouvelleTentative, + messageErreur: messageErreur, + prochainEssai: prochainEssai, + }) + .where(eq(logsRappels.id, log.id)); + + console.log(`[Rappels Retry] Échec pour ${apprenant[0].email}, tentative ${nouvelleTentative}/3. Prochain essai: ${prochainEssai.toLocaleString()}`); + } + nbEchecs++; + } + } catch (error) { + console.error(`[Rappels Retry] Erreur lors du traitement du log ${log.id}:`, error); + } + } + + console.log(`[Rappels Retry] Traitement terminé: ${nbSucces} succès, ${nbEchecs} échecs`); + } catch (error) { + console.error("[Rappels Retry] Erreur lors du processus de retry:", error); + } +} + +/** + * Initialise le scheduler de retry + * Vérifie toutes les heures s'il y a des rappels à réessayer + */ +export function initRappelRetryScheduler() { + console.log("[Rappels Retry] Initialisation du scheduler de réessai automatique"); + + // Exécuter immédiatement au démarrage + processRappelRetry(); + + // Puis exécuter toutes les heures + setInterval(() => { + processRappelRetry(); + }, 60 * 60 * 1000); // 1 heure en millisecondes + + console.log("[Rappels Retry] Scheduler de réessai initialisé - vérification toutes les heures"); +} diff --git a/deployment-package/source_server/rappelScheduler.ts b/deployment-package/source_server/rappelScheduler.ts new file mode 100644 index 0000000..e1a1869 --- /dev/null +++ b/deployment-package/source_server/rappelScheduler.ts @@ -0,0 +1,294 @@ +import { getDb } from "./db"; +import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema"; +import { eq, and, gte, lte, sql, inArray } from "drizzle-orm"; +import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService"; +import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb"; +import { notifyOwner } from "./_core/notification"; +import { processRemerciementsAutomatiques } from "./remerciementScheduler"; + +/** + * Job automatique qui vérifie quotidiennement les séquences à venir + * et envoie les rappels J-7 et J-1 aux apprenants inscrits + */ +export async function processRappelsAutomatiques() { + console.log("[Rappels] Démarrage du traitement des rappels automatiques..."); + + const db = await getDb(); + if (!db) { + console.error("[Rappels] Base de données non disponible"); + return; + } + + try { + // Récupérer tous les rappels actifs + const rappelsActifs = await db + .select() + .from(rappels) + .where(eq(rappels.actif, true)); + + console.log(`[Rappels] ${rappelsActifs.length} rappel(s) actif(s) trouvé(s)`); + + for (const rappel of rappelsActifs) { + await processRappel(rappel); + } + + console.log("[Rappels] Traitement terminé"); + } catch (error) { + console.error("[Rappels] Erreur lors du traitement des rappels:", error); + } +} + +async function processRappel(rappel: any) { + console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`); + + const db = await getDb(); + if (!db) return; + + try { + // Calculer la date cible selon le timing (pré ou post-formation) + const dateCible = new Date(); + if (rappel.timing === 'pre_formation') { + dateCible.setDate(dateCible.getDate() + rappel.joursAvant); + } else { + dateCible.setDate(dateCible.getDate() - rappel.joursAvant); + } + dateCible.setHours(0, 0, 0, 0); + + const dateCibleFin = new Date(dateCible); + dateCibleFin.setHours(23, 59, 59, 999); + + console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`); + + // Vérifier si ce rappel est associé à des dates spécifiques + const datesAssociees = rappel.dateFormationIds || []; + const filtreDates = datesAssociees.length > 0; + + console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`); + + // Récupérer les dates de formation concernées + let datesConcernees; + if (filtreDates) { + // Seulement les dates associées à ce rappel + datesConcernees = await db + .select({ + dateFormation: datesFormation, + sequence: sequences, + formation: formations, + formateur: formateurs, + }) + .from(datesFormation) + .leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .where( + and( + inArray(datesFormation.id, datesAssociees), + gte(datesFormation.dateDebut, dateCible), + lte(datesFormation.dateDebut, dateCibleFin) + ) + ); + } else { + // Toutes les dates correspondant à la date cible + datesConcernees = await db + .select({ + dateFormation: datesFormation, + sequence: sequences, + formation: formations, + formateur: formateurs, + }) + .from(datesFormation) + .leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .where( + and( + gte(datesFormation.dateDebut, dateCible), + lte(datesFormation.dateDebut, dateCibleFin) + ) + ); + } + + console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`); + + // Grouper les dates par séquence pour envoyer un seul email par apprenant + const sequencesMap = new Map(); + + for (const { dateFormation, sequence, formation, formateur } of datesConcernees) { + if (!dateFormation || !sequence || !formation) continue; + + if (!sequencesMap.has(sequence.id)) { + sequencesMap.set(sequence.id, { + sequence, + formation, + formateur, + dates: [], + }); + } + sequencesMap.get(sequence.id)!.dates.push(dateFormation); + } + + console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`); + + for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) { + if (!sequence || !formation) continue; + + // Récupérer toutes les dates de cette séquence pour l'email + const toutesLesDates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, sequence.id)) + .orderBy(datesFormation.ordre); + + const formateurNom = formateur?.nom || ""; + + // Récupérer tous les apprenants inscrits à cette séquence + const inscrits = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequence.id), + eq(inscriptions.statut, "confirmee") + ) + ); + + console.log(`[Rappels] ${inscrits.length} apprenant(s) inscrit(s) à la séquence ${sequence.nom}`); + + // Envoyer le rappel à chaque apprenant + let nbEnvoyes = 0; + let nbEchecs = 0; + const echecs: Array<{ email: string; erreur: string }> = []; + + for (const { apprenant } of inscrits) { + if (!apprenant || !apprenant.email) continue; + + // Vérifier si le rappel a déjà été envoyé + const dejaEnvoye = await rappelDejaEnvoye(rappel.id, sequence.id, apprenant.id); + if (dejaEnvoye) { + console.log(`[Rappels] Rappel déjà envoyé à ${apprenant.email} pour la séquence ${sequence.nom} - ignoré`); + continue; + } + + try { + // Choisir la fonction d'envoi selon le type de rappel + if (rappel.templateType === "rappel") { + // Rappel J-7 + await sendRappelJ7Email({ + apprenantEmail: apprenant.email, + apprenantPrenom: apprenant.prenom, + apprenantNom: apprenant.nom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: toutesLesDates.map(d => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: sequence.lieu || "", + formateur: formateurNom, + attachmentUrl: rappel.urlFichier || undefined, + attachmentFilename: rappel.nomFichier || undefined, + attachmentMimeType: rappel.typeFichier || undefined, + }); + console.log(`[Rappels] Email J-7 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`); + } else if (rappel.templateType === "rappelJ1") { + // Rappel J-1 + await sendRappelJ1Email({ + apprenantEmail: apprenant.email, + apprenantPrenom: apprenant.prenom, + apprenantNom: apprenant.nom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: toutesLesDates.map(d => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: sequence.lieu || "", + formateur: formateurNom, + attachmentUrl: rappel.urlFichier || undefined, + attachmentFilename: rappel.nomFichier || undefined, + attachmentMimeType: rappel.typeFichier || undefined, + }); + console.log(`[Rappels] Email J-1 envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`); + } + + // Logger le succès + await logRappelEnvoye({ + rappelId: rappel.id, + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + typeRappel: rappel.templateType, + statut: "success", + dateSequence: toutesLesDates[0].dateDebut, + }); + nbEnvoyes++; + } catch (emailError: any) { + const messageErreur = emailError?.message || String(emailError); + console.error(`[Rappels] Erreur lors de l'envoi à ${apprenant.email}:`, emailError); + + // Logger l'échec + await logRappelEnvoye({ + rappelId: rappel.id, + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + typeRappel: rappel.templateType, + statut: "failed", + messageErreur: messageErreur, + dateSequence: toutesLesDates[0].dateDebut, + }); + nbEchecs++; + echecs.push({ email: apprenant.email, erreur: messageErreur }); + } + } + + // Notifier l'administrateur en cas d'échecs + if (nbEchecs > 0) { + const listeEchecs = echecs.map(e => `- ${e.email}: ${e.erreur}`).join("\n"); + await notifyOwner({ + title: `⚠️ Échecs d'envoi de rappels`, + content: `${nbEchecs} rappel(s) n'ont pas pu être envoyés pour la séquence "${sequence.nom}" (${formation.nom}):\n\n${listeEchecs}\n\nRappel: ${rappel.nom} (J-${rappel.joursAvant})\nEnvois réussis: ${nbEnvoyes}`, + }); + } + + console.log(`[Rappels] Séquence ${sequence.nom}: ${nbEnvoyes} envoi(s) réussi(s), ${nbEchecs} échec(s)`); + } + + // Mettre à jour la date de dernière exécution + await db + .update(rappels) + .set({ derniereExecution: new Date() }) + .where(eq(rappels.id, rappel.id)); + + } catch (error) { + console.error(`[Rappels] Erreur lors du traitement du rappel ${rappel.nom}:`, error); + } +} + +/** + * Initialise le scheduler pour exécuter les rappels automatiques + * Vérifie toutes les heures s'il y a des rappels à envoyer + */ +export function initRappelScheduler() { + console.log("[Rappels] Initialisation du scheduler de rappels automatiques"); + + // Exécuter immédiatement au démarrage + processRappelsAutomatiques(); + processRemerciementsAutomatiques(); + + // Puis exécuter toutes les heures + setInterval(() => { + processRappelsAutomatiques(); + processRemerciementsAutomatiques(); + }, 60 * 60 * 1000); // 1 heure en millisecondes + + console.log("[Rappels] Scheduler initialisé - vérification toutes les heures (rappels + remerciements)"); +} diff --git a/deployment-package/source_server/remerciementScheduler.ts b/deployment-package/source_server/remerciementScheduler.ts new file mode 100644 index 0000000..3c12bb3 --- /dev/null +++ b/deployment-package/source_server/remerciementScheduler.ts @@ -0,0 +1,351 @@ +import { getDb } from "./db"; +import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs, questionnaires, envoisQuestionnaires, logsNotifications } from "../drizzle/schema"; +import { eq, and, lte, sql } from "drizzle-orm"; +import { sendRemerciementPostFormation } from "./emailService"; +import { logNotification } from "./notificationLogsDb"; +import crypto from "crypto"; + +/** + * Génère un token unique pour accéder au questionnaire + */ +function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * Récupère ou crée un lien questionnaire pour un apprenant + */ +async function getOrCreateQuestionnaireLink( + apprenantId: number, + sequenceId: number +): Promise { + const db = await getDb(); + if (!db) return null; + + try { + // Chercher un questionnaire de satisfaction actif + const [questionnaire] = await db + .select() + .from(questionnaires) + .where( + and( + eq(questionnaires.type, "satisfaction"), + eq(questionnaires.actif, true) + ) + ) + .limit(1); + + if (!questionnaire) { + console.log("[Remerciements] Aucun questionnaire de satisfaction actif trouvé"); + return null; + } + + // Vérifier si un envoi existe déjà + const [envoiExistant] = await db + .select() + .from(envoisQuestionnaires) + .where( + and( + eq(envoisQuestionnaires.questionnaireId, questionnaire.id), + eq(envoisQuestionnaires.apprenantId, apprenantId), + eq(envoisQuestionnaires.sequenceId, sequenceId) + ) + ) + .limit(1); + + if (envoiExistant) { + // Retourner le lien existant + const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000"; + return `${baseUrl}/questionnaire/${envoiExistant.token}`; + } + + // Créer un nouvel envoi + const token = generateToken(); + await db.insert(envoisQuestionnaires).values({ + questionnaireId: questionnaire.id, + apprenantId, + sequenceId, + token, + dateEnvoi: new Date(), + dateReponse: null, + }); + + const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000"; + return `${baseUrl}/questionnaire/${token}`; + } catch (error) { + console.error("[Remerciements] Erreur lors de la création du lien questionnaire:", error); + return null; + } +} + +/** + * Vérifie si un remerciement a déjà été envoyé pour une inscription en vérifiant dans la base de données + */ +async function remerciementDejaEnvoye(apprenantId: number, sequenceId: number): Promise { + const db = await getDb(); + if (!db) return false; + + try { + const [existing] = await db + .select({ id: logsNotifications.id }) + .from(logsNotifications) + .where( + and( + eq(logsNotifications.type, "remerciement"), + eq(logsNotifications.apprenantId, apprenantId), + eq(logsNotifications.sequenceId, sequenceId), + eq(logsNotifications.statut, "success") + ) + ) + .limit(1); + + return !!existing; + } catch (error) { + console.error("[Remerciements] Erreur lors de la vérification du log:", error); + return false; + } +} + +/** + * Job automatique qui vérifie quotidiennement les séquences terminées + * et envoie les emails de remerciement aux apprenants + */ +export async function processRemerciementsAutomatiques() { + console.log("[Remerciements] Démarrage du traitement des remerciements post-formation..."); + + const db = await getDb(); + if (!db) { + console.error("[Remerciements] Base de données non disponible"); + return; + } + + try { + const now = new Date(); + const hier = new Date(); + hier.setDate(hier.getDate() - 1); + hier.setHours(23, 59, 59, 999); + + // Récupérer les séquences dont la dernière date est passée (terminées hier ou avant) + const sequencesTerminees = await db + .select({ + sequence: sequences, + formation: formations, + formateur: formateurs, + derniereDate: sql`MAX(${datesFormation.dateFin})`.as('derniereDate'), + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .innerJoin(datesFormation, eq(sequences.id, datesFormation.sequenceId)) + .where(eq(sequences.statut, 'ouverte')) // Séquences encore ouvertes (pas encore marquées terminées) + .groupBy(sequences.id, formations.id, formateurs.id) + .having(lte(sql`MAX(${datesFormation.dateFin})`, hier)); + + console.log(`[Remerciements] ${sequencesTerminees.length} séquence(s) terminée(s) trouvée(s)`); + + for (const { sequence, formation, formateur, derniereDate } of sequencesTerminees) { + await processRemerciementSequence(sequence, formation, formateur, derniereDate); + } + + console.log("[Remerciements] Traitement terminé"); + } catch (error) { + console.error("[Remerciements] Erreur lors du traitement des remerciements:", error); + } +} + +async function processRemerciementSequence( + sequence: any, + formation: any, + formateur: any | null, + derniereDate: Date +) { + console.log(`[Remerciements] Traitement de la séquence: ${sequence.nom} (terminée le ${derniereDate})`); + + const db = await getDb(); + if (!db) return; + + try { + // Récupérer les inscriptions confirmées pour cette séquence + const inscriptionsConfirmees = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequence.id), + eq(inscriptions.statut, 'confirmee') + ) + ); + + console.log(`[Remerciements] ${inscriptionsConfirmees.length} inscription(s) confirmée(s) pour ${sequence.nom}`); + + let envoyesCount = 0; + let errorsCount = 0; + + for (const { inscription, apprenant } of inscriptionsConfirmees) { + // Vérifier si le remerciement a déjà été envoyé (vérification en base de données) + const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequence.id); + if (dejaEnvoye) { + console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}`); + continue; + } + + try { + // Générer le lien questionnaire + const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequence.id); + + await sendRemerciementPostFormation({ + apprenantEmail: apprenant.email, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + formateurNom: formateur?.nom, + lienQuestionnaire: lienQuestionnaire || undefined, + }); + + // Logger la notification (sert aussi de marqueur pour éviter les doublons) + await logNotification({ + type: "remerciement", + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "success", + metadata: { lienQuestionnaire }, + }); + + envoyesCount++; + console.log(`[Remerciements] Email envoyé à ${apprenant.email}`); + } catch (error: any) { + // Logger l'échec + await logNotification({ + type: "remerciement", + sequenceId: sequence.id, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "failed", + messageErreur: error.message, + }); + + errorsCount++; + console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); + } + } + + console.log(`[Remerciements] Séquence ${sequence.nom}: ${envoyesCount} envoyé(s), ${errorsCount} erreur(s)`); + + // Optionnel: Marquer la séquence comme terminée + // await db.update(sequences).set({ statut: 'terminee' }).where(eq(sequences.id, sequence.id)); + + } catch (error) { + console.error(`[Remerciements] Erreur pour la séquence ${sequence.nom}:`, error); + } +} + +/** + * Envoie manuellement un email de remerciement pour une séquence spécifique + * Utilisé par l'interface admin pour envoyer les remerciements à la demande + */ +export async function envoyerRemerciementsSequence(sequenceId: number): Promise<{ sent: number; failed: number }> { + const db = await getDb(); + if (!db) { + throw new Error("Base de données non disponible"); + } + + // Récupérer la séquence avec formation et formateur + const sequenceData = await db + .select({ + sequence: sequences, + formation: formations, + formateur: formateurs, + }) + .from(sequences) + .innerJoin(formations, eq(sequences.formationId, formations.id)) + .leftJoin(formateurs, eq(sequences.formateurId, formateurs.id)) + .where(eq(sequences.id, sequenceId)) + .limit(1); + + if (sequenceData.length === 0) { + throw new Error("Séquence introuvable"); + } + + const { sequence, formation, formateur } = sequenceData[0]; + + // Récupérer les inscriptions confirmées + const inscriptionsConfirmees = await db + .select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) + .where( + and( + eq(inscriptions.sequenceId, sequenceId), + eq(inscriptions.statut, 'confirmee') + ) + ); + + let sent = 0; + let failed = 0; + + for (const { inscription, apprenant } of inscriptionsConfirmees) { + // Vérifier si le remerciement a déjà été envoyé + const dejaEnvoye = await remerciementDejaEnvoye(apprenant.id, sequenceId); + if (dejaEnvoye) { + console.log(`[Remerciements] Remerciement déjà envoyé pour ${apprenant.email}, ignoré`); + continue; + } + + try { + // Générer le lien questionnaire + const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequenceId); + + await sendRemerciementPostFormation({ + apprenantEmail: apprenant.email, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + formateurNom: formateur?.nom, + lienQuestionnaire: lienQuestionnaire || undefined, + }); + + // Logger la notification + await logNotification({ + type: "remerciement", + sequenceId, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "success", + metadata: { lienQuestionnaire }, + }); + + sent++; + } catch (error: any) { + // Logger l'échec + await logNotification({ + type: "remerciement", + sequenceId, + apprenantId: apprenant.id, + emailDestinataire: apprenant.email, + sujet: `Merci pour votre participation - ${formation.nom}`, + statut: "failed", + messageErreur: error.message, + }); + + console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error); + failed++; + } + } + + return { sent, failed }; +} diff --git a/deployment-package/source_server/routers.ts b/deployment-package/source_server/routers.ts new file mode 100644 index 0000000..5ee1383 --- /dev/null +++ b/deployment-package/source_server/routers.ts @@ -0,0 +1,2380 @@ +import { COOKIE_NAME } from "@shared/const"; +import { getSessionCookieOptions } from "./_core/cookies"; +import { systemRouter } from "./_core/systemRouter"; +import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; +import { parseLocalDateTime } from "./dateUtils"; +import { z } from "zod"; +import * as db from "./db"; +import * as analyticsDb from "./analyticsDb"; +import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService"; +import { TRPCError } from "@trpc/server"; +import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService"; +import { logNotification } from "./notificationLogsDb"; +import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; +import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; + +// Procédure admin uniquement +const adminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role !== 'admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' }); + } + return next({ ctx }); +}); + +export const appRouter = router({ + system: systemRouter, + auth: router({ + me: publicProcedure.query(opts => opts.ctx.user), + logout: publicProcedure.mutation(({ ctx }) => { + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); + return { + success: true, + } as const; + }), + }), + + // ===== FORMATIONS ===== + formations: router({ + list: protectedProcedure.query(async ({ ctx }) => { + // Si l'utilisateur est formateur, ne montrer que les formations pour lesquelles il a des séquences + if (ctx.user.role === 'formateur' && ctx.user.formateurId) { + return db.getFormationsByFormateur(ctx.user.formateurId); + } else if (ctx.user.role === 'admin') { + return db.getFormations(); + } else { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + }), + + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + return db.getFormationById(input.id); + }), + + getByLien: publicProcedure.input(z.object({ lien: z.string() })).query(async ({ input }) => { + return db.getFormationByLien(input.lien); + }), + + create: adminProcedure.input(z.object({ + nom: z.string().min(1), + description: z.string().optional(), + lienUnique: z.string().min(1), + actif: z.boolean().optional(), + })).mutation(async ({ input }) => { + await db.createFormation(input); + return { success: true }; + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + nom: z.string().min(1).optional(), + description: z.string().optional(), + lienUnique: z.string().min(1).optional(), + actif: z.boolean().optional(), + })).mutation(async ({ input }) => { + const { id, ...data } = input; + await db.updateFormation(id, data); + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + await db.deleteFormation(input.id); + return { success: true }; + }), + + importExcel: adminProcedure.input(z.object({ + fileBase64: z.string(), + })).mutation(async ({ input }) => { + try { + // Décoder le fichier base64 + const buffer = Buffer.from(input.fileBase64, 'base64'); + + // Parser le fichier Excel + const { formations, sequences } = parseExcelFile(buffer); + + // Valider les données + const validation = validateImportData(formations, sequences); + + if (!validation.valid) { + return { + success: false, + formationsCreated: 0, + sequencesCreated: 0, + errors: validation.errors, + warnings: validation.warnings, + }; + } + + // Importer dans la base de données + const result = await importToDatabase(formations, sequences); + + return { + ...result, + warnings: [...validation.warnings, ...result.warnings], + }; + } catch (error: any) { + return { + success: false, + formationsCreated: 0, + sequencesCreated: 0, + errors: [`Erreur lors du traitement du fichier: ${error.message}`], + warnings: [], + }; + } + }), + + previewExcel: adminProcedure.input(z.object({ + fileBase64: z.string(), + })).mutation(async ({ input }) => { + try { + const buffer = Buffer.from(input.fileBase64, 'base64'); + const { formations, sequences } = parseExcelFile(buffer); + const validation = validateImportData(formations, sequences); + + return { + formations, + sequences, + validation, + }; + } catch (error: any) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Erreur lors de la lecture du fichier: ${error.message}`, + }); + } + }), + }), + + // ===== APPRENANTS ===== + apprenants: router({ + list: protectedProcedure.query(async ({ ctx }) => { + // Si l'utilisateur est formateur, ne montrer que les apprenants inscrits à ses séquences + if (ctx.user.role === 'formateur' && ctx.user.formateurId) { + return db.getApprenantsByFormateur(ctx.user.formateurId); + } else if (ctx.user.role === 'admin') { + return db.getApprenants(); + } else { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + }), + + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + return db.getApprenantById(input.id); + }), + + getByEmail: publicProcedure.input(z.object({ email: z.string() })).query(async ({ input }) => { + return db.getApprenantByEmail(input.email); + }), + + create: adminProcedure.input(z.object({ + nom: z.string().min(1), + prenom: z.string().min(1), + email: z.string().email(), + codeEtablissement: z.string().min(1), + fonction: z.enum(["directeur", "chef_service", "autre"]), + })).mutation(async ({ input }) => { + await db.createApprenant(input); + return { success: true }; + }), + + // Procédure publique pour créer un apprenant lors de l'inscription + createPublic: publicProcedure.input(z.object({ + nom: z.string().min(1), + prenom: z.string().min(1), + email: z.string().email(), + codeEtablissement: z.string().min(1), + fonction: z.enum(["directeur", "chef_service", "autre"]), + })).mutation(async ({ input }) => { + await db.createApprenant(input); + return { success: true }; + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + nom: z.string().min(1).optional(), + prenom: z.string().min(1).optional(), + email: z.string().email().optional(), + codeEtablissement: z.string().min(1).optional(), + fonction: z.enum(["directeur", "chef_service", "autre"]).optional(), + })).mutation(async ({ input }) => { + const { id, ...data } = input; + await db.updateApprenant(id, data); + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + await db.deleteApprenant(input.id); + return { success: true }; + }), + + getInscriptions: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + const results = await db.getInscriptionsByApprenant(input.id); + // Récupérer les informations de formation et séquence pour chaque inscription + const inscriptionsAvecDetails = await Promise.all( + results.map(async (result: any) => { + const insc = result.inscription; + if (!insc) return null; + const sequence = await db.getSequenceById(insc.sequenceId); + if (!sequence) return null; + const formation = await db.getFormationById(sequence.formationId); + const dates = await db.getDatesBySequence(insc.sequenceId); + return { + ...insc, + sequence: { ...sequence, dates }, + formation, + }; + }) + ); + return inscriptionsAvecDetails.filter(i => i !== null); + }), + }), + + // ===== FORMATEURS ===== + formateurs: router({ + list: adminProcedure.query(async () => { + return db.getFormateurs(); + }), + + // Tableau de bord formateur + dashboardStats: protectedProcedure.query(async ({ ctx }) => { + if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); + } + const formateurDb = await import('./formateurDb'); + return formateurDb.getFormateurDashboardStats(ctx.user.formateurId); + }), + + prochainesSequences: protectedProcedure + .input(z.object({ limit: z.number().optional().default(10) })) + .query(async ({ ctx, input }) => { + if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); + } + const formateurDb = await import('./formateurDb'); + return formateurDb.getFormateurProchainesSequences(ctx.user.formateurId, input.limit); + }), + + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + return db.getFormateurById(input.id); + }), + + create: adminProcedure.input(z.object({ + nom: z.string().min(1), + email: z.string().email().optional(), + })).mutation(async ({ input }) => { + await db.createFormateur(input); + return { success: true }; + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + nom: z.string().min(1).optional(), + email: z.string().email().nullable().optional(), + })).mutation(async ({ input }) => { + const { id, ...data } = input; + await db.updateFormateur(id, data); + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + await db.deleteFormateur(input.id); + return { success: true }; + }), + }), + + // ===== SÉQUENCES ===== + sequences: router({ + list: protectedProcedure.query(async ({ ctx }) => { + let seqs; + + // Si l'utilisateur est formateur, ne montrer que ses séquences + if (ctx.user.role === 'formateur' && ctx.user.formateurId) { + seqs = await db.getSequencesByFormateur(ctx.user.formateurId); + } else if (ctx.user.role === 'admin') { + seqs = await db.getSequencesWithFormation(); + } else { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); + } + // Récupérer les dates et le nombre d'inscrits pour chaque séquence + const sequencesAvecDates = await Promise.all( + seqs.map(async (seq) => { + const dates = await db.getDatesBySequence(seq.id); + const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee'); + + // Récupérer les rappels associés à chaque date + const datesAvecRappels = await Promise.all( + dates.map(async (date) => { + const rappels = await db.getRappelsByDateFormation(date.id); + return { ...date, rappels }; + }) + ); + + return { ...seq, dates: datesAvecRappels, nbInscrits }; + }) + ); + return sequencesAvecDates; + }), + + listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => { + const seqs = await db.getSequencesByFormation(input.formationId); + // Récupérer les dates et le nombre d'inscrits pour chaque séquence + const sequencesAvecDates = await Promise.all( + seqs.map(async (seq) => { + const dates = await db.getDatesBySequence(seq.id); + const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee'); + return { ...seq, dates, nbInscrits }; + }) + ); + return sequencesAvecDates; + }), + + getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + const seq = await db.getSequenceById(input.id); + if (!seq) return null; + const dates = await db.getDatesBySequence(input.id); + const nbInscrits = await db.countInscriptionsBySequence(input.id, 'confirmee'); + return { ...seq, dates, nbInscrits }; + }), + + create: adminProcedure.input(z.object({ + formationId: z.number(), + nom: z.string().min(1), + lieu: z.string().min(1), + publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]), + formateurId: z.number().nullable().optional(), + capaciteMax: z.number().default(12), + dateBlocage: z.string(), + statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(), + dates: z.array(z.object({ + dateDebut: z.string(), + dateFin: z.string(), + ordre: z.number(), + })).min(1).max(4), + })).mutation(async ({ input }) => { + const { dates, ...sequenceData } = input; + + // Créer la séquence + const result = await db.createSequence({ + ...sequenceData, + dateBlocage: parseLocalDateTime(sequenceData.dateBlocage), + }); + + // Récupérer l'ID de la séquence créée + const sequenceId = Number(result[0].insertId); + + // Créer les dates de formation + for (const date of dates) { + await db.createDateFormation({ + sequenceId, + dateDebut: parseLocalDateTime(date.dateDebut), + dateFin: parseLocalDateTime(date.dateFin), + ordre: date.ordre, + }); + } + + return { success: true, sequenceId }; + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + formationId: z.number(), + nom: z.string().min(1), + lieu: z.string().min(1), + publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]), + formateurId: z.number().nullable().optional(), + capaciteMax: z.number(), + dateBlocage: z.string(), + statut: z.enum(["ouverte", "bloquee", "terminee"]), + dates: z.array(z.object({ + dateDebut: z.string(), + dateFin: z.string(), + ordre: z.number(), + })).min(1).max(4), + })).mutation(async ({ input }) => { + const { id, dates, ...sequenceData } = input; + + console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2)); + + // Mettre à jour la séquence + const updateData: any = { + ...sequenceData, + dateBlocage: parseLocalDateTime(sequenceData.dateBlocage), + }; + + console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2)); + await db.updateSequence(id, updateData); + console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès'); + + // Supprimer les anciennes dates + await db.deleteDatesBySequence(id); + console.log('[UPDATE SEQUENCE] Anciennes dates supprimées'); + + // Créer les nouvelles dates + for (const date of dates) { + console.log('[UPDATE SEQUENCE] Création date:', date); + await db.createDateFormation({ + sequenceId: id, + dateDebut: parseLocalDateTime(date.dateDebut), + dateFin: parseLocalDateTime(date.dateFin), + ordre: date.ordre, + }); + } + console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées'); + + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + // Supprimer d'abord les dates associées + await db.deleteDatesBySequence(input.id); + // Puis supprimer la séquence + await db.deleteSequence(input.id); + return { success: true }; + }), + }), + + // ===== INSCRIPTIONS ===== + inscriptions: router({ + // Liste des inscriptions avec dates de formation (pour formateurs) + listWithDates: protectedProcedure + .input(z.object({ sequenceId: z.number() })) + .query(async ({ input }) => { + const { getInscriptionsWithDates } = await import("./inscriptionWithDates"); + return getInscriptionsWithDates(input.sequenceId); + }), + + listAll: adminProcedure.query(async () => { + return db.getAllInscriptions(); + }), + + listBySequence: adminProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => { + return db.getInscriptionsBySequence(input.sequenceId); + }), + + listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => { + return db.getInscriptionsByApprenant(input.apprenantId); + }), + + listByApprenantWithDates: publicProcedure + .input(z.object({ apprenantId: z.number() })) + .query(async ({ input }) => { + const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates"); + return getInscriptionsByApprenantWithDates(input.apprenantId); + }), + + checkExisting: publicProcedure.input(z.object({ + apprenantId: z.number(), + sequenceId: z.number(), + })).query(async ({ input }) => { + return db.checkExistingInscription(input.apprenantId, input.sequenceId); + }), + + inscrire: publicProcedure.input(z.object({ + apprenantId: z.number(), + sequenceId: z.number(), + })).mutation(async ({ input }) => { + // Vérifier si la séquence existe + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + } + + // Vérifier si la séquence est bloquée + const now = new Date(); + if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)' + }); + } + + // Vérifier si l'apprenant a déjà une inscription + const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Vous êtes déjà inscrit à cette séquence' + }); + } + + // Vérifier la capacité + const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee'; + + await db.createInscription({ + apprenantId: input.apprenantId, + sequenceId: input.sequenceId, + statut, + }); + + // Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates + const inscriptionSequence = await db.getSequenceById(input.sequenceId); + const inscriptionApprenant = await db.getApprenantById(input.apprenantId); + const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null; + const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : []; + + if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) { + // Utiliser la première date pour l'email principal + const premiereDate = dates[0]; + + await sendInscriptionConfirmation({ + apprenantEmail: inscriptionApprenant.email, + apprenantNom: inscriptionApprenant.nom, + apprenantPrenom: inscriptionApprenant.prenom, + apprenantFonction: inscriptionApprenant.fonction, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: inscriptionSequence.lieu, + statut, + }); + + // Notification au formateur si présent + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + if (formateur && formateur.email) { + const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + try { + await sendNotificationFormateurNouvelleInscription({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: inscriptionApprenant.nom, + apprenantPrenom: inscriptionApprenant.prenom, + apprenantFonction: inscriptionApprenant.fonction, + apprenantEtablissement: inscriptionApprenant.codeEtablissement, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + nbInscrits: nbInscritsActuel, + capaciteMax: inscriptionSequence.capaciteMax, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + // Logger la notification + await logNotification({ + type: "notification_formateur_inscription", + sequenceId: input.sequenceId, + apprenantId: inscriptionApprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`, + statut: "success", + }); + console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`); + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_formateur_inscription", + sequenceId: input.sequenceId, + apprenantId: inscriptionApprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`, + statut: "failed", + messageErreur: e.message, + }); + console.error(`[Notification] Erreur envoi email formateur:`, e); + } + } + } + + // Alerte capacité atteinte pour les admins ET le formateur + const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + if (nbInscritsApres >= inscriptionSequence.capaciteMax) { + try { + const admins = await db.getAdminUsers(); + const adminEmails = admins.filter(a => a.email).map(a => a.email!); + if (adminEmails.length > 0) { + const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente'); + let formateurNom: string | undefined; + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + formateurNom = formateur?.nom; + } + await sendAlerteCapaciteAtteinte({ + adminEmails, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + capaciteMax: inscriptionSequence.capaciteMax, + nbInscrits: nbInscritsApres, + nbListeAttente, + formateurNom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`); + } + + // Notification au formateur pour capacité atteinte + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + if (formateur && formateur.email) { + const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService'); + await sendNotificationFormateurCapaciteAtteinte({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + capaciteMax: inscriptionSequence.capaciteMax, + nbInscrits: nbInscritsApres, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`); + } + } + } catch (e) { + console.error(`[Notification] Erreur envoi alerte capacité:`, e); + } + } + } + + return { success: true, statut }; + }), + + desinscrire: publicProcedure.input(z.object({ + apprenantId: z.number(), + sequenceId: z.number(), + })).mutation(async ({ input }) => { + // Vérifier si la séquence existe + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + } + + // Vérifier si la séquence est bloquée + const now = new Date(); + if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)' + }); + } + + // Trouver l'inscription + const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId); + if (!inscription) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' }); + } + + // Récupérer les infos avant la mise à jour pour les notifications + const apprenant = await db.getApprenantById(input.apprenantId); + const formation = await db.getFormationById(sequence.formationId); + + await db.updateInscription(inscription.id, { statut: 'annulee' }); + + // Notifications après désinscription + if (apprenant && formation) { + // Notification au formateur + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + if (formateur && formateur.email) { + const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); + try { + await sendNotificationFormateurAnnulation({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction || '', + formationNom: formation.nom, + sequenceNom: sequence.nom, + nbInscrits: nbInscritsApres, + capaciteMax: sequence.capaciteMax, + }); + // Logger la notification + await logNotification({ + type: "notification_formateur_annulation", + sequenceId: input.sequenceId, + apprenantId: apprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Annulation d'inscription - ${formation.nom}`, + statut: "success", + }); + console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`); + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_formateur_annulation", + sequenceId: input.sequenceId, + apprenantId: apprenant.id, + formateurId: formateur.id, + emailDestinataire: formateur.email, + sujet: `Annulation d'inscription - ${formation.nom}`, + statut: "failed", + messageErreur: e.message, + }); + console.error(`[Notification] Erreur envoi email formateur:`, e); + } + } + } + + // Notifier le premier en liste d'attente qu'une place s'est libérée + const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId); + const premierEnAttente = inscriptionsListeAttente + .filter(i => i.inscription.statut === 'liste_attente') + .sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0]; + + if (premierEnAttente && premierEnAttente.apprenant) { + try { + await sendNotificationPlaceDisponible({ + apprenantEmail: premierEnAttente.apprenant.email, + apprenantNom: premierEnAttente.apprenant.nom, + apprenantPrenom: premierEnAttente.apprenant.prenom, + apprenantFonction: premierEnAttente.apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + positionListeAttente: 1, + delaiReponse: 48, + }); + // Logger la notification + await logNotification({ + type: "notification_liste_attente", + sequenceId: input.sequenceId, + apprenantId: premierEnAttente.apprenant.id, + emailDestinataire: premierEnAttente.apprenant.email, + sujet: `Place disponible - ${formation.nom}`, + statut: "success", + }); + console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`); + } catch (e: any) { + // Logger l'échec + await logNotification({ + type: "notification_liste_attente", + sequenceId: input.sequenceId, + apprenantId: premierEnAttente.apprenant.id, + emailDestinataire: premierEnAttente.apprenant.email, + sujet: `Place disponible - ${formation.nom}`, + statut: "failed", + messageErreur: e.message, + }); + console.error(`[Notification] Erreur envoi email liste d'attente:`, e); + } + } + } + + return { success: true }; + }), + + updateStatut: adminProcedure.input(z.object({ + id: z.number(), + statut: z.enum(['confirmee', 'liste_attente', 'annulee']), + })).mutation(async ({ input }) => { + // Récupérer l'inscription avant modification + const inscriptionAvant = await db.getInscriptionById(input.id); + const ancienStatut = inscriptionAvant?.statut; + + await db.updateInscription(input.id, { statut: input.statut }); + + // Si passage à annulée, notifier le formateur + if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) { + const sequence = await db.getSequenceById(inscriptionAvant.sequenceId); + const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId); + + if (sequence && apprenant && sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + const formation = await db.getFormationById(sequence.formationId); + + if (formateur && formateur.email && formation) { + const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee'); + + try { + await sendNotificationFormateurAnnulation({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction || '', + formationNom: formation.nom, + sequenceNom: sequence.nom, + nbInscrits: nbInscritsApres, + capaciteMax: sequence.capaciteMax, + }); + console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`); + + // Si une place se libère (passage de capacité max à disponible) + if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) { + const { sendNotificationFormateurPlaceDisponible } = await import('./emailService'); + await sendNotificationFormateurPlaceDisponible({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + formationNom: formation.nom, + sequenceNom: sequence.nom, + capaciteMax: sequence.capaciteMax, + nbInscrits: nbInscritsApres, + placesDisponibles: sequence.capaciteMax - nbInscritsApres, + }); + console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`); + } + } catch (e) { + console.error(`[Notification] Erreur envoi notification formateur:`, e); + } + } + } + } + + return { success: true }; + }), + + sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel', 'rappel_j1']) })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + } + + const formation = await db.getFormationById(sequence.formationId); + if (!formation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); + } + + const dates = await db.getDatesBySequence(sequence.id); + if (dates.length === 0) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' }); + } + + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const recipients = inscriptions + .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) + .map(i => ({ + email: i.apprenant!.email, + prenom: i.apprenant!.prenom, + nom: i.apprenant!.nom, + fonction: i.apprenant!.fonction, + })); + + console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`); + console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email)); + + let formateurNom: string | undefined; + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + formateurNom = formateur?.nom; + } + + const result = await sendGroupEmail({ + recipients, + formationNom: formation.nom, + sequenceNom: sequence.nom, + type: input.type, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + formateur: formateurNom, + }); + + console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`); + + return { + ...result, + message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)` + }; + }), + + emailPreview: adminProcedure + .input(z.object({ + sequenceId: z.number(), + type: z.enum(['teaser', 'rappel', 'rappel_j1']), + apprenantId: z.number().optional(), + })) + .query(async ({ input }) => { + const { generateEmailPreview } = await import('./emailPreview'); + return await generateEmailPreview(input); + }), + + exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + + const formation = await db.getFormationById(sequence.formationId); + if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); + + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + + const data = inscriptions + .filter(i => i.apprenant) + .map(i => ({ + nom: i.apprenant!.nom, + prenom: i.apprenant!.prenom, + email: i.apprenant!.email, + codeEtablissement: i.apprenant!.codeEtablissement, + fonction: i.apprenant!.fonction, + statut: i.inscription.statut, + dateInscription: i.inscription.dateInscription, + })); + + const buffer = await generateExcelExport({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + publicCible: sequence.publicCible, + inscriptions: data, + }); + + return { + buffer: buffer.toString('base64'), + filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`, + }; + }), + + exportPDF: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + + const formation = await db.getFormationById(sequence.formationId); + if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); + + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + + const data = inscriptions + .filter(i => i.apprenant) + .map(i => ({ + nom: i.apprenant!.nom, + prenom: i.apprenant!.prenom, + email: i.apprenant!.email, + codeEtablissement: i.apprenant!.codeEtablissement, + fonction: i.apprenant!.fonction, + statut: i.inscription.statut, + dateInscription: i.inscription.dateInscription, + })); + + const buffer = await generatePDFExport({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + publicCible: sequence.publicCible, + inscriptions: data, + }); + + return { + buffer: buffer.toString('base64'), + filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`, + }; + }), + + exportFeuillePresence: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + + const formation = await db.getFormationById(sequence.formationId); + if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); + + const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); + const dates = await db.getDatesBySequence(input.sequenceId); + + // Récupérer le formateur si défini + let formateurNom: string | undefined = undefined; + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + if (formateur) { + formateurNom = formateur.nom; + } + } + + const data = inscriptions + .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) + .map(i => ({ + nom: i.apprenant!.nom, + prenom: i.apprenant!.prenom, + codeEtablissement: i.apprenant!.codeEtablissement, + fonction: i.apprenant!.fonction, + })); + + const buffer = await generateFeuillePresence({ + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + lieu: sequence.lieu, + publicCible: sequence.publicCible, + formateur: formateurNom, + apprenants: data, + }); + + return { + buffer: buffer.toString('base64'), + filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`, + }; + }), + }), + + // ===== GESTION DES UTILISATEURS ===== + users: router({ + list: adminProcedure.query(async () => { + return db.getAllUsers(); + }), + + listWithFormateurs: adminProcedure.query(async () => { + const dbInstance = await import("./db").then(m => m.getDb()); + if (!dbInstance) throw new Error("Base de données non disponible"); + + const { formateurs, users } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + + // Récupérer tous les formateurs + const allFormateurs = await dbInstance.select().from(formateurs); + + // Récupérer tous les utilisateurs + const allUsers = await dbInstance.select().from(users); + + // Créer une map des formateurs liés à des comptes + const formateurIdToUser = new Map(); + allUsers.forEach(user => { + if (user.formateurId) { + formateurIdToUser.set(user.formateurId, user); + } + }); + + // Combiner les données + return { + users: allUsers, + formateurs: allFormateurs.map(formateur => ({ + ...formateur, + hasAccount: formateurIdToUser.has(formateur.id), + user: formateurIdToUser.get(formateur.id) || null, + })), + }; + }), + + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + return db.getUserById(input.id); + }), + + create: adminProcedure.input(z.object({ + openId: z.string().min(1), + name: z.string().optional(), + email: z.string().email().optional(), + username: z.string().min(3).optional(), + password: z.string().min(6).optional(), + role: z.enum(["user", "admin", "formateur"]).default("user"), + isActive: z.boolean().default(true), + formateurId: z.number().optional(), + })).mutation(async ({ input }) => { + await db.createUser(input); + return { success: true }; + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + name: z.string().optional(), + email: z.string().email().optional(), + username: z.string().min(3).optional(), + password: z.string().min(6).optional(), + role: z.enum(["user", "admin", "formateur"]).optional(), + isActive: z.boolean().optional(), + formateurId: z.number().optional(), + })).mutation(async ({ input }) => { + const { id, ...data } = input; + await db.updateUser(id, data); + return { success: true }; + }), + + toggleStatus: adminProcedure.input(z.object({ + id: z.number(), + isActive: z.boolean(), + })).mutation(async ({ input }) => { + await db.toggleUserStatus(input.id, input.isActive); + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + await db.deleteUser(input.id); + return { success: true }; + }), + + requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + const user = await db.getUserById(input.id); + if (!user) { + throw new Error("Utilisateur introuvable"); + } + + if (!user.email) { + throw new Error("Cet utilisateur n'a pas d'adresse email"); + } + + // Générer un token unique + const crypto = await import('crypto'); + const token = crypto.randomBytes(32).toString('hex'); + + // Définir l'expiration à 24h + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 24); + + // Sauvegarder le token en base + await db.createPasswordResetToken(user.id, token, expiresAt); + + // Générer le lien de réinitialisation + // TODO: Remplacer par l'URL réelle de votre application en production + const resetLink = `https://votre-domaine.com/reset-password?token=${token}`; + + // Envoyer l'email + const emailService = await import('./emailService'); + const emailSent = await emailService.sendPasswordResetEmail({ + apprenantEmail: user.email, + apprenantNom: user.name || 'Utilisateur', + apprenantPrenom: '', + resetLink, + }); + + if (!emailSent) { + throw new Error("Échec de l'envoi de l'email"); + } + + return { success: true, message: "Email de réinitialisation envoyé" }; + }), + }), + + // ===== GESTION DES TEMPLATES D'EMAILS ===== + emailTemplates: router({ + list: adminProcedure.query(async () => { + return db.getAllEmailTemplates(); + }), + + getByType: adminProcedure + .input(z.object({ type: z.string() })) + .query(async ({ input }) => { + return db.getEmailTemplateByType(input.type); + }), + + upsert: adminProcedure + .input(z.object({ + type: z.string(), + name: z.string(), + logoUrl: z.string().nullable(), + primaryColor: z.string(), + headerBgColor: z.string(), + headerTextColor: z.string(), + headerTitle: z.string(), + bodyContent: z.string().nullable(), + footerText: z.string().nullable(), + active: z.boolean(), + })) + .mutation(async ({ input }) => { + return db.upsertEmailTemplate(input); + }), + + delete: adminProcedure + .input(z.object({ type: z.string() })) + .mutation(async ({ input }) => { + await db.deleteEmailTemplate(input.type); + return { success: true }; + }), + + initializeDefaults: adminProcedure.mutation(async () => { + await db.initializeDefaultEmailTemplates(); + return { success: true }; + }), + }), + + // ===== GESTION DES RAPPELS ===== + rappels: router({ + list: adminProcedure.query(async () => { + return db.getRappels(); + }), + + create: adminProcedure + .input(z.object({ + nom: z.string(), + templateType: z.enum(["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]), + timing: z.enum(["pre_formation", "post_formation"]).default("pre_formation"), + joursAvant: z.number(), + heureEnvoi: z.string().default("09:00"), + actif: z.boolean(), + dateFormationIds: z.array(z.number()).optional(), + fichier: z.object({ + nomFichier: z.string(), + urlFichier: z.string(), + s3Key: z.string(), + typeFichier: z.string(), + tailleFichier: z.number(), + }).nullable().optional(), + })) + .mutation(async ({ input }) => { + const { dateFormationIds, ...rappelData } = input; + const result: any = await db.createRappel(rappelData); + + // Créer les associations rappel-date si des dates sont spécifiées + if (dateFormationIds && dateFormationIds.length > 0) { + const rappelId = result.insertId || result[0]?.insertId; + if (rappelId) { + await db.createRappelDates(Number(rappelId), dateFormationIds); + } + } + + return result; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + nom: z.string().optional(), + templateType: z.enum(["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).optional(), + timing: z.enum(["pre_formation", "post_formation"]).optional(), + joursAvant: z.number().optional(), + heureEnvoi: z.string().optional(), + actif: z.boolean().optional(), + dateFormationIds: z.array(z.number()).optional(), + fichier: z.object({ + nomFichier: z.string(), + urlFichier: z.string(), + s3Key: z.string(), + typeFichier: z.string(), + tailleFichier: z.number(), + }).nullable().optional(), + })) + .mutation(async ({ input }) => { + const { id, dateFormationIds, ...data } = input; + await db.updateRappel(id, data); + + // Mettre à jour les associations rappel-date si spécifiées + if (dateFormationIds !== undefined) { + await db.deleteRappelDates(id); + if (dateFormationIds.length > 0) { + await db.createRappelDates(id, dateFormationIds); + } + } + + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await db.deleteRappel(input.id); + return { success: true }; + }), + + toggleActif: adminProcedure + .input(z.object({ id: z.number(), actif: z.boolean() })) + .mutation(async ({ input }) => { + await db.updateRappel(input.id, { actif: input.actif }); + return { success: true }; + }), + + envoyerAutomatiques: publicProcedure + .mutation(async () => { + const { processRappelsAutomatiques } = await import('./rappelScheduler'); + const result = await processRappelsAutomatiques(); + return result; + }), + + testerRappel: adminProcedure + .input(z.object({ + sequenceId: z.number(), + typeRappel: z.enum(['rappel', 'rappelJ1']), + })) + .mutation(async ({ input }) => { + const sequence = await db.getSequenceById(input.sequenceId); + if (!sequence) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); + } + + const formation = await db.getFormationById(sequence.formationId); + if (!formation) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); + } + + // Récupérer le formateur si présent + let formateurNom: string | undefined; + if (sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + formateurNom = formateur?.nom; + } + + // Récupérer les dates de la séquence + const dates = await db.getDatesBySequence(input.sequenceId); + + // Récupérer les inscrits + const inscrits = await db.getInscriptionsBySequence(input.sequenceId); + const inscritsConfirmes = inscrits.filter(i => i.inscription.statut === 'confirmee'); + + if (inscritsConfirmes.length === 0) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun apprenant inscrit confirmé pour cette séquence' }); + } + + // Envoyer le rappel de test au premier inscrit uniquement + const premierInscrit = inscritsConfirmes[0]; + const apprenant = premierInscrit.apprenant; + + if (!apprenant || !apprenant.email) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Apprenant introuvable ou sans email' }); + } + + const { sendRappelJ7Email, sendRappelJ1Email } = await import('./emailService'); + + try { + if (input.typeRappel === 'rappel') { + await sendRappelJ7Email({ + apprenantEmail: apprenant.email, + apprenantPrenom: apprenant.prenom, + apprenantNom: apprenant.nom, + apprenantFonction: apprenant.fonction || '', + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map((d: any) => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: sequence.lieu || '', + formateur: formateurNom, + }); + } else { + await sendRappelJ1Email({ + apprenantEmail: apprenant.email, + apprenantPrenom: apprenant.prenom, + apprenantNom: apprenant.nom, + apprenantFonction: apprenant.fonction || '', + formationNom: formation.nom, + sequenceNom: sequence.nom, + dates: dates.map((d: any) => ({ + dateDebut: d.dateDebut, + dateFin: d.dateFin, + ordre: d.ordre + })), + lieu: sequence.lieu || '', + formateur: formateurNom, + }); + } + + return { + success: true, + message: `Rappel de test envoyé à ${apprenant.email}`, + destinataire: apprenant.email, + }; + } catch (error: any) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Erreur lors de l'envoi: ${error.message}`, + }); + } + }), + + historique: adminProcedure + .input(z.object({ + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + sequenceId: z.number().optional(), + statut: z.enum(['success', 'failed']).optional(), + email: z.string().optional(), + limit: z.number().default(100), + offset: z.number().default(0), + })) + .query(async ({ input }) => { + const { getLogsRappelsWithFilters } = await import('./rappelDb'); + return getLogsRappelsWithFilters({ + dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, + dateFin: input.dateFin ? new Date(input.dateFin) : undefined, + sequenceId: input.sequenceId, + statut: input.statut, + email: input.email, + limit: input.limit, + offset: input.offset, + }); + }), + + statistiques: adminProcedure.query(async () => { + const { getStatsRappels } = await import('./rappelDb'); + return getStatsRappels(); + }), + + evolution: adminProcedure.query(async () => { + const { getEvolutionEnvois } = await import('./rappelDb'); + return getEvolutionEnvois(); + }), + }), + + emailConfig: router({ + get: adminProcedure.query(async () => { + return db.getActiveEmailConfig(); + }), + + upsert: adminProcedure + .input(z.object({ + provider: z.enum(["resend", "smtp", "simulation"]), + apiKey: z.string().nullable(), + fromEmail: z.string().email(), + fromName: z.string(), + mode: z.enum(["simulation", "production"]), + domainVerified: z.boolean(), + // Champs SMTP + smtpHost: z.string().nullable().optional(), + smtpPort: z.number().nullable().optional(), + smtpSecure: z.enum(["none", "tls", "ssl"]).nullable().optional(), + smtpUser: z.string().nullable().optional(), + smtpPassword: z.string().nullable().optional(), + })) + .mutation(async ({ input }) => { + await db.upsertEmailConfig(input); + return { success: true }; + }), + + testEmail: adminProcedure + .input(z.object({ + toEmail: z.string().email(), + })) + .mutation(async ({ input }) => { + // Importer le service d'envoi + const { sendEmail } = await import("./_core/emailSender"); + + try { + const success = await sendEmail({ + to: input.toEmail, + subject: "Test d'envoi d'email - Formation Manager Itinova", + html: ` +

    Test réussi !

    +

    Cet email de test a été envoyé avec succès depuis votre configuration SMTP.

    +

    Votre configuration d'envoi d'emails fonctionne correctement.

    + `, + }); + + return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" }; + } catch (error: any) { + return { success: false, message: error.message }; + } + }), + }), + + // ===== PARAMÈTRES DE L'APPLICATION ===== + parametres: router({ + get: adminProcedure.query(async () => { + const { getParametres } = await import("./parametresDb"); + return getParametres(); + }), + + update: adminProcedure + .input(z.object({ + urlPublique: z.string().url().optional(), + delaiExpirationQR: z.number().int().positive().optional(), + dureeValiditeToken: z.number().int().positive().optional(), + notificationsActives: z.boolean().optional(), + })) + .mutation(async ({ input, ctx }) => { + const { updateParametres } = await import("./parametresDb"); + await updateParametres(input, ctx.user.id, ctx.user.name || "Administrateur"); + return { success: true }; + }), + + updateUrlPublique: adminProcedure + .input(z.object({ + urlPublique: z.string().url(), + })) + .mutation(async ({ input, ctx }) => { + const { updateUrlPublique } = await import("./parametresDb"); + await updateUrlPublique(input.urlPublique, ctx.user.id, ctx.user.name || "Administrateur"); + return { success: true }; + }), + + testerUrl: adminProcedure + .input(z.object({ + url: z.string(), + })) + .mutation(async ({ input }) => { + const { testerUrl } = await import("./parametresDb"); + return testerUrl(input.url); + }), + + getHistorique: adminProcedure + .input(z.object({ + limit: z.number().int().positive().default(50), + })) + .query(async ({ input }) => { + const { getHistoriqueParametres } = await import("./parametresDb"); + return getHistoriqueParametres(input.limit); + }), + }), + + // ===== ANALYTICS ===== + analytics: router({ + globalStats: adminProcedure.query(async () => { + return analyticsDb.getGlobalStats(); + }), + + inscriptionsByMonth: adminProcedure + .input(z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + })) + .query(async ({ input }) => { + const startDate = input.startDate ? new Date(input.startDate) : undefined; + const endDate = input.endDate ? new Date(input.endDate) : undefined; + return analyticsDb.getInscriptionsByMonth(startDate, endDate); + }), + + tauxRemplissageByMonth: adminProcedure.query(async () => { + return analyticsDb.getTauxRemplissageByMonth(); + }), + + participationByEtablissement: adminProcedure.query(async () => { + return analyticsDb.getParticipationByEtablissement(); + }), + + participationByFonction: adminProcedure.query(async () => { + return analyticsDb.getParticipationByFonction(); + }), + + exportExcel: adminProcedure + .input(z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + })) + .mutation(async ({ input }) => { + const startDate = input.startDate ? new Date(input.startDate) : undefined; + const endDate = input.endDate ? new Date(input.endDate) : undefined; + const buffer = await generateAnalyticsExcel(startDate, endDate); + return { + data: buffer.toString('base64'), + filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.xlsx`, + }; + }), + + exportPDF: adminProcedure + .input(z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + })) + .mutation(async ({ input }) => { + const startDate = input.startDate ? new Date(input.startDate) : undefined; + const endDate = input.endDate ? new Date(input.endDate) : undefined; + const buffer = await generateAnalyticsPDF(startDate, endDate); + return { + data: buffer.toString('base64'), + filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.pdf`, + }; + }), + }), + + // ===== QUESTIONNAIRES ===== + questionnaires: router({ + list: adminProcedure.query(async () => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getAllQuestionnaires(); + }), + + getById: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionnaireById(input.id); + }), + + create: adminProcedure + .input(z.object({ + titre: z.string(), + type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]), + description: z.string().optional(), + formationId: z.number().nullable().optional(), + actif: z.boolean().default(true), + envoiAutomatique: z.boolean().default(false), + delaiEnvoiJours: z.number().default(1), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const id = await questionnaireDb.createQuestionnaire(input); + return { id, success: true }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + titre: z.string().optional(), + type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(), + description: z.string().optional(), + formationId: z.number().nullable().optional(), + actif: z.boolean().optional(), + envoiAutomatique: z.boolean().optional(), + delaiEnvoiJours: z.number().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.updateQuestionnaire(id, data); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.deleteQuestionnaire(input.id); + return { success: true }; + }), + + getStats: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionnaireStats(input.id); + }), + + getReponsesDetaillees: adminProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id); + }), + + exportExcel: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const { exportQuestionnaireToExcel } = await import("./questionnaireExport"); + const buffer = await exportQuestionnaireToExcel(input.id); + return { + data: buffer.toString('base64'), + filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`, + }; + }), + + exportPDF: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const { exportQuestionnaireToPDF } = await import("./questionnaireExport"); + const buffer = await exportQuestionnaireToPDF(input.id); + return { + data: buffer.toString('base64'), + filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`, + }; + }), + }), + + // ===== QUESTIONS ===== + questions: router({ + list: adminProcedure + .input(z.object({ questionnaireId: z.number() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId); + }), + + create: adminProcedure + .input(z.object({ + questionnaireId: z.number(), + ordre: z.number(), + texte: z.string(), + typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]), + options: z.string().optional(), + echelleMin: z.number().optional(), + echelleMax: z.number().optional(), + echelleLabelMin: z.string().optional(), + echelleLabelMax: z.string().optional(), + obligatoire: z.boolean().default(false), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const id = await questionnaireDb.createQuestion(input); + return { id, success: true }; + }), + + update: adminProcedure + .input(z.object({ + id: z.number(), + ordre: z.number().optional(), + texte: z.string().optional(), + typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(), + options: z.string().optional(), + echelleMin: z.number().optional(), + echelleMax: z.number().optional(), + echelleLabelMin: z.string().optional(), + echelleLabelMax: z.string().optional(), + obligatoire: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...data } = input; + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.updateQuestion(id, data); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + await questionnaireDb.deleteQuestion(input.id); + return { success: true }; + }), + }), + + // ===== RÉPONSES QUESTIONNAIRES (PUBLIC) ===== + reponses: router({ + getByToken: publicProcedure + .input(z.object({ token: z.string() })) + .query(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + const envoi = await questionnaireDb.getEnvoiByToken(input.token); + if (!envoi) return null; + + const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId); + const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId); + + return { + envoi, + questionnaire, + questions, + }; + }), + + submit: publicProcedure + .input(z.object({ + token: z.string(), + reponses: z.array(z.object({ + questionId: z.number(), + reponseNumerique: z.number().optional(), + reponseTexte: z.string().optional(), + })), + })) + .mutation(async ({ input }) => { + const questionnaireDb = await import("./questionnaireDb"); + + // Récupérer l'envoi + const envoi = await questionnaireDb.getEnvoiByToken(input.token); + if (!envoi) throw new Error("Token invalide"); + if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu"); + + // Créer la réponse au questionnaire + const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({ + questionnaireId: envoi.questionnaireId, + apprenantId: envoi.apprenantId, + sequenceId: envoi.sequenceId, + }); + + // Enregistrer chaque réponse + for (const reponse of input.reponses) { + await questionnaireDb.createReponseQuestion({ + reponseQuestionnaireId, + questionId: reponse.questionId, + reponseNumerique: reponse.reponseNumerique, + reponseTexte: reponse.reponseTexte, + }); + } + + // Mettre à jour l'envoi + await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, { + dateReponse: new Date(), + }); + + // Envoyer une notification à l'administrateur + try { + const { notifyOwner } = await import('./_core/notification'); + const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId); + + await notifyOwner({ + title: 'Nouvelle réponse au questionnaire', + content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`, + }); + } catch (error) { + console.error('[Notification] Erreur lors de l\'envoi de la notification:', error); + // Ne pas bloquer la réponse si la notification échoue + } + + return { success: true }; + }), + + envoyerAutomatiques: adminProcedure + .mutation(async () => { + const { processEnvoisAutomatiques } = await import('./questionnaireScheduler'); + const result = await processEnvoisAutomatiques(); + return result; + }), + }), + + // ===== SUIVI QUESTIONNAIRES ===== + questionnaireSuivi: router({ + statsGlobales: adminProcedure.query(async () => { + const suiviDb = await import("./questionnaireSuiviDb"); + return suiviDb.getStatsGlobales(); + }), + + statsByFormation: adminProcedure.query(async () => { + const suiviDb = await import("./questionnaireSuiviDb"); + return suiviDb.getStatsByFormation(); + }), + + evolutionTemporelle: adminProcedure + .input(z.object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + })) + .query(async ({ input }) => { + const suiviDb = await import("./questionnaireSuiviDb"); + return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate); + }), + + statsByFormateur: adminProcedure.query(async () => { + const suiviDb = await import("./questionnaireSuiviDb"); + return suiviDb.getStatsByFormateur(); + }), + }), + + // ===== ESPACE FORMATEUR ===== + formateur: router({ + calendrier: protectedProcedure + .input(z.object({ + formateurId: z.number(), + dateDebut: z.date().optional(), + dateFin: z.date().optional(), + })) + .query(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin); + }), + + apprenants: protectedProcedure + .input(z.object({ sequenceId: z.number() })) + .query(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.getApprenantsSequence(input.sequenceId); + }), + + supports: protectedProcedure + .input(z.object({ sequenceId: z.number() })) + .query(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.getSupportsSequence(input.sequenceId); + }), + + ajouterSupport: protectedProcedure + .input(z.object({ + sequenceId: z.number(), + formateurId: z.number(), + nomFichier: z.string(), + typeFichier: z.string(), + tailleFichier: z.number(), + urlFichier: z.string(), + s3Key: z.string(), + description: z.string().optional(), + })) + .mutation(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.ajouterSupport(input); + }), + + supprimerSupport: protectedProcedure + .input(z.object({ + supportId: z.number(), + formateurId: z.number(), + })) + .mutation(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId); + + // Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé + // car il n'y a pas de fonction storageDelete dans l'API actuelle + + return { success: true, support }; + }), + + validerPresence: protectedProcedure + .input(z.object({ + inscriptionId: z.number(), + present: z.boolean(), + })) + .mutation(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + await formateurDb.validerPresence(input.inscriptionId, input.present); + return { success: true }; + }), + + historique: protectedProcedure + .input(z.object({ + formateurId: z.number(), + limit: z.number().optional(), + })) + .query(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit); + }), + + detailSequence: protectedProcedure + .input(z.object({ + sequenceId: z.number(), + formateurId: z.number(), + })) + .query(async ({ input }) => { + const formateurDb = await import("./formateurDb"); + return formateurDb.getDetailSequence(input.sequenceId, input.formateurId); + }), + }), + + // ===== ÉTABLISSEMENTS ===== + etablissements: router({ + list: adminProcedure.query(async () => { + const { getEtablissementsStats } = await import("./etablissementsDb"); + return getEtablissementsStats(); + }), + + getDetail: adminProcedure + .input(z.object({ codeEtablissement: z.string() })) + .query(async ({ input }) => { + const { getEtablissementDetail } = await import("./etablissementsDb"); + return getEtablissementDetail(input.codeEtablissement); + }), + }), + + // ===== NOTIFICATIONS ===== + notifications: router({ + envoyerRemerciements: adminProcedure + .input(z.object({ sequenceId: z.number() })) + .mutation(async ({ input }) => { + const { envoyerRemerciementsSequence } = await import("./remerciementScheduler"); + return envoyerRemerciementsSequence(input.sequenceId); + }), + + envoyerRemerciementsAutomatiques: adminProcedure + .mutation(async () => { + const { processRemerciementsAutomatiques } = await import("./remerciementScheduler"); + await processRemerciementsAutomatiques(); + return { success: true }; + }), + + // Historique des notifications + historique: adminProcedure + .input(z.object({ + type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(), + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + statut: z.enum(["success", "failed"]).optional(), + email: z.string().optional(), + sequenceId: z.number().optional(), + limit: z.number().default(50), + offset: z.number().default(0), + })) + .query(async ({ input }) => { + const { getNotificationLogs } = await import("./notificationLogsDb"); + return getNotificationLogs({ + type: input.type, + dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, + dateFin: input.dateFin ? new Date(input.dateFin) : undefined, + statut: input.statut, + email: input.email, + sequenceId: input.sequenceId, + limit: input.limit, + offset: input.offset, + }); + }), + + // Statistiques des notifications + statistiques: adminProcedure + .input(z.object({ + dateDebut: z.string().optional(), + dateFin: z.string().optional(), + })) + .query(async ({ input }) => { + const { getNotificationStats } = await import("./notificationLogsDb"); + return getNotificationStats({ + dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, + dateFin: input.dateFin ? new Date(input.dateFin) : undefined, + }); + }), + }), + + // Attestations de formation + attestations: router({ + // Générer une attestation pour une inscription + generer: adminProcedure + .input(z.object({ + inscriptionId: z.number(), + })) + .mutation(async ({ input }) => { + const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService"); + const db = await import("./db").then(m => m.getDb()); + if (!db) throw new Error("Base de données non disponible"); + + // Vérifier si l'attestation existe déjà + const { inscriptions } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (!inscription) { + throw new Error("Inscription introuvable"); + } + + const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId); + if (attestationExistante) { + return { + success: true, + attestation: attestationExistante, + message: "Attestation déjà générée", + }; + } + + // Générer le PDF + const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); + + // Enregistrer dans la base + const attestationId = await enregistrerAttestation( + input.inscriptionId, + inscription.apprenantId, + inscription.sequenceId, + s3Key, + pdfUrl + ); + + const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId); + + return { + success: true, + attestation, + message: "Attestation générée avec succès", + }; + }), + + // Récupérer l'attestation d'un apprenant pour une séquence + get: publicProcedure + .input(z.object({ + apprenantId: z.number(), + sequenceId: z.number(), + })) + .query(async ({ input }) => { + const { getAttestation } = await import("./attestationService"); + return getAttestation(input.apprenantId, input.sequenceId); + }), + + // Récupérer toutes les attestations d'un apprenant + listApprenant: publicProcedure + .input(z.object({ + apprenantId: z.number(), + })) + .query(async ({ input }) => { + const { getAttestationsApprenant } = await import("./attestationService"); + return getAttestationsApprenant(input.apprenantId); + }), + + // Récupérer la configuration des attestations + getConfig: adminProcedure + .query(async () => { + const { getOrCreateConfigAttestation } = await import("./attestationService"); + return getOrCreateConfigAttestation(); + }), + + // Mettre à jour la configuration des attestations + updateConfig: adminProcedure + .input(z.object({ + nomSignataire: z.string().optional(), + fonctionSignataire: z.string().optional(), + texteAttestation: z.string().optional(), + logoUrl: z.string().optional(), + logoS3Key: z.string().optional(), + signatureUrl: z.string().optional(), + signatureS3Key: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { updateConfigAttestation } = await import("./attestationService"); + await updateConfigAttestation(input); + return { success: true }; + }), + + // Récupérer l'historique des envois d'attestations + historique: adminProcedure + .query(async () => { + const { getHistoriqueAttestations } = await import("./attestationsDb"); + return getHistoriqueAttestations(); + }), + + // Récupérer l'historique pour une séquence spécifique + historiqueBySequence: adminProcedure + .input(z.object({ + sequenceId: z.number(), + })) + .query(async ({ input }) => { + const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb"); + return getHistoriqueAttestationsBySequence(input.sequenceId); + }), + + // Générer une prévisualisation du modèle d'attestation + previsualiser: adminProcedure + .mutation(async () => { + const { genererPreviewAttestation } = await import("./attestationService"); + return genererPreviewAttestation(); + }), + }), + + // ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) ===== + presences: router({ + // Générer un QR code pour une séquence + generateQRCode: protectedProcedure + .input(z.object({ + sequenceId: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator"); + const { getDb } = await import("./db"); + const { sequences } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Vérifier que la séquence existe + const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1); + if (sequence.length === 0) { + throw new Error("Séquence introuvable"); + } + + // Générer un nouveau token si nécessaire + let token = sequence[0].qrCodeToken; + if (!token) { + token = generateQRToken(); + await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId)); + } + + // Générer le QR code + const qrCodeDataURL = await generateQRCodeDataURL(token); + + return { + token, + qrCodeDataURL, + }; + }), + + // Valider une présence (scan QR code ou manuel) + valider: publicProcedure + .input(z.object({ + token: z.string().optional(), + inscriptionId: z.number(), + dateFormationId: z.number(), + modeValidation: z.enum(["qrcode", "manuel"]), + validateurId: z.number().optional(), + commentaire: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { validerPresence } = await import("./presenceDb"); + const { getDb } = await import("./db"); + const { sequences, inscriptions } = await import("../drizzle/schema"); + const { eq } = await import("drizzle-orm"); + + // Si mode QR code, vérifier le token + if (input.modeValidation === "qrcode" && input.token) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Récupérer l'inscription pour vérifier la séquence + const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (inscription.length === 0) { + throw new Error("Inscription introuvable"); + } + + // Vérifier que le token correspond à la séquence + const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1); + if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) { + throw new Error("QR code invalide"); + } + } + + const result = await validerPresence({ + inscriptionId: input.inscriptionId, + dateFormationId: input.dateFormationId, + modeValidation: input.modeValidation, + validateurId: input.validateurId, + commentaire: input.commentaire, + }); + + // Vérifier si toutes les présences sont validées + const { checkAllPresencesValidated } = await import("./presenceDb"); + const allValidated = await checkAllPresencesValidated(input.inscriptionId); + + // Si toutes les présences sont validées, générer l'attestation automatiquement + if (allValidated) { + try { + const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService"); + + // Générer le PDF + const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); + + // Récupérer les données de l'inscription + const db = await getDb(); + if (db) { + const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (inscriptionData.length > 0) { + // Enregistrer l'attestation en base + await enregistrerAttestation( + input.inscriptionId, + inscriptionData[0].apprenantId, + inscriptionData[0].sequenceId, + s3Key, + pdfUrl + ); + } + } + + return { + ...result, + attestationGenerated: true, + attestationUrl: pdfUrl, + }; + } catch (error) { + console.error("Erreur lors de la génération de l'attestation:", error); + // Ne pas bloquer la validation de présence si l'attestation échoue + } + } + + return result; + }), + + // Lister les présences pour une séquence + listBySequence: protectedProcedure + .input(z.object({ + sequenceId: z.number(), + })) + .query(async ({ input }) => { + const { getPresencesBySequence } = await import("./presenceDb"); + return getPresencesBySequence(input.sequenceId); + }), + + // Lister les présences pour une inscription + listByInscription: publicProcedure + .input(z.object({ + inscriptionId: z.number(), + })) + .query(async ({ input }) => { + const { getPresencesByInscription } = await import("./presenceDb"); + return getPresencesByInscription(input.inscriptionId); + }), + + // Supprimer une présence + delete: protectedProcedure + .input(z.object({ + presenceId: z.number(), + })) + .mutation(async ({ input }) => { + const { supprimerPresence } = await import("./presenceDb"); + return supprimerPresence(input.presenceId); + }), + + // Vérifier si toutes les présences sont validées pour une inscription + checkAllValidated: publicProcedure + .input(z.object({ + inscriptionId: z.number(), + })) + .query(async ({ input }) => { + const { checkAllPresencesValidated } = await import("./presenceDb"); + return checkAllPresencesValidated(input.inscriptionId); + }), + }), + + // ===== GESTION DES ATTESTATIONS ===== + gestionAttestations: router({ + // Récupérer la configuration d'une formation + getFormationConfig: adminProcedure + .input(z.object({ formationId: z.number() })) + .query(async ({ input }) => { + const { getFormationConfig } = await import("./gestionAttestationsDb"); + return getFormationConfig(input.formationId); + }), + + // Mettre à jour la configuration d'une formation + updateFormationConfig: adminProcedure + .input(z.object({ + formationId: z.number(), + modeAttestation: z.enum(["auto", "manuel"]), + modeEnvoi: z.enum(["auto", "manuel"]), + })) + .mutation(async ({ input }) => { + const { updateFormationConfig } = await import("./gestionAttestationsDb"); + return updateFormationConfig( + input.formationId, + input.modeAttestation, + input.modeEnvoi + ); + }), + + // Récupérer les apprenants avec leur statut d'attestation + getApprenantsWithStatus: adminProcedure + .input(z.object({ formationId: z.number() })) + .query(async ({ input }) => { + try { + const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb"); + const results = await getApprenantsWithAttestationStatus(input.formationId); + console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId); + return results; + } catch (error) { + console.error("[gestionAttestations] Error fetching apprenants:", error); + throw error; + } + }), + + // Uploader un document d'attestation + uploadDocument: adminProcedure + .input(z.object({ + inscriptionId: z.number(), + documentUrl: z.string(), + documentS3Key: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const { uploadAttestationDocument } = await import("./gestionAttestationsDb"); + return uploadAttestationDocument( + input.inscriptionId, + input.documentUrl, + input.documentS3Key, + ctx.user.id + ); + }), + + // Supprimer un document d'attestation + deleteDocument: adminProcedure + .input(z.object({ attestationId: z.number() })) + .mutation(async ({ input }) => { + const { deleteAttestationDocument } = await import("./gestionAttestationsDb"); + return deleteAttestationDocument(input.attestationId); + }), + + // Envoyer une attestation par email + sendAttestation: adminProcedure + .input(z.object({ + attestationId: z.number(), + apprenantEmail: z.string(), + apprenantNom: z.string(), + apprenantPrenom: z.string(), + formationNom: z.string(), + pdfUrl: z.string(), + })) + .mutation(async ({ input }) => { + const { sendAttestationEmail } = await import("./emailService"); + const { markAttestationAsSent } = await import("./gestionAttestationsDb"); + + // Envoyer l'email + await sendAttestationEmail({ + to: input.apprenantEmail, + apprenantNom: input.apprenantNom, + apprenantPrenom: input.apprenantPrenom, + formationNom: input.formationNom, + pdfUrl: input.pdfUrl, + }); + + // Marquer comme envoyé + await markAttestationAsSent(input.attestationId); + + return { success: true }; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/deployment-package/source_server/storage.ts b/deployment-package/source_server/storage.ts new file mode 100644 index 0000000..6cdf862 --- /dev/null +++ b/deployment-package/source_server/storage.ts @@ -0,0 +1,102 @@ +// Preconfigured storage helpers for Manus WebDev templates +// Uses the Biz-provided storage proxy (Authorization: Bearer ) + +import { ENV } from './_core/env'; + +type StorageConfig = { baseUrl: string; apiKey: string }; + +function getStorageConfig(): StorageConfig { + const baseUrl = ENV.forgeApiUrl; + const apiKey = ENV.forgeApiKey; + + if (!baseUrl || !apiKey) { + throw new Error( + "Storage proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY" + ); + } + + return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey }; +} + +function buildUploadUrl(baseUrl: string, relKey: string): URL { + const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl)); + url.searchParams.set("path", normalizeKey(relKey)); + return url; +} + +async function buildDownloadUrl( + baseUrl: string, + relKey: string, + apiKey: string +): Promise { + const downloadApiUrl = new URL( + "v1/storage/downloadUrl", + ensureTrailingSlash(baseUrl) + ); + downloadApiUrl.searchParams.set("path", normalizeKey(relKey)); + const response = await fetch(downloadApiUrl, { + method: "GET", + headers: buildAuthHeaders(apiKey), + }); + return (await response.json()).url; +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + +function normalizeKey(relKey: string): string { + return relKey.replace(/^\/+/, ""); +} + +function toFormData( + data: Buffer | Uint8Array | string, + contentType: string, + fileName: string +): FormData { + const blob = + typeof data === "string" + ? new Blob([data], { type: contentType }) + : new Blob([data as any], { type: contentType }); + const form = new FormData(); + form.append("file", blob, fileName || "file"); + return form; +} + +function buildAuthHeaders(apiKey: string): HeadersInit { + return { Authorization: `Bearer ${apiKey}` }; +} + +export async function storagePut( + relKey: string, + data: Buffer | Uint8Array | string, + contentType = "application/octet-stream" +): Promise<{ key: string; url: string }> { + const { baseUrl, apiKey } = getStorageConfig(); + const key = normalizeKey(relKey); + const uploadUrl = buildUploadUrl(baseUrl, key); + const formData = toFormData(data, contentType, key.split("/").pop() ?? key); + const response = await fetch(uploadUrl, { + method: "POST", + headers: buildAuthHeaders(apiKey), + body: formData, + }); + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText); + throw new Error( + `Storage upload failed (${response.status} ${response.statusText}): ${message}` + ); + } + const url = (await response.json()).url; + return { key, url }; +} + +export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> { + const { baseUrl, apiKey } = getStorageConfig(); + const key = normalizeKey(relKey); + return { + key, + url: await buildDownloadUrl(baseUrl, key, apiKey), + }; +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6100909..b928c8b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,13 +120,6 @@ "when": 1770047974825, "tag": "0016_aromatic_king_cobra", "breakpoints": true - }, - { - "idx": 17, - "version": "5", - "when": 1770056161269, - "tag": "0017_lethal_namor", - "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 446e81a..53cdc4c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -414,12 +414,8 @@ export type InsertSupportFormation = typeof supportsFormation.$inferInsert; */ export const presences = mysqlTable("presences", { id: int("id").autoincrement().primaryKey(), - inscriptionId: int("inscriptionId"), + inscriptionId: int("inscriptionId").notNull(), dateFormationId: int("dateFormationId").notNull(), - /** Type d'utilisateur qui s'émarge (apprenant ou formateur) */ - typeUtilisateur: mysqlEnum("typeUtilisateur", ["apprenant", "formateur"]).notNull().default("apprenant"), - /** ID du formateur (si typeUtilisateur = formateur) */ - formateurId: int("formateurId"), /** Période de la journée (matin ou après-midi) */ periode: mysqlEnum("periode", ["matin", "apres_midi"]).notNull().default("matin"), heurePresence: timestamp("heurePresence").defaultNow().notNull(), @@ -428,7 +424,7 @@ export const presences = mysqlTable("presences", { /** ID de l'utilisateur qui a validé (formateur ou admin) */ validateurId: int("validateurId"), commentaire: text("commentaire"), - /** URL de la signature manuscrite (apprenant ou formateur) */ + /** URL de la signature manuscrite de l'apprenant */ signatureUrl: varchar("signatureUrl", { length: 500 }), /** Clé S3 de la signature manuscrite */ signatureS3Key: varchar("signatureS3Key", { length: 500 }), diff --git a/formation-manager-update-20260202-150825.tar.gz b/formation-manager-update-20260202-150825.tar.gz deleted file mode 100644 index 8020aa1..0000000 Binary files a/formation-manager-update-20260202-150825.tar.gz and /dev/null differ diff --git a/server/__tests__/emargement-formateur.test.ts b/server/__tests__/emargement-formateur.test.ts deleted file mode 100644 index 77dc768..0000000 --- a/server/__tests__/emargement-formateur.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { getDb } from '../db'; -import { validerPresenceFormateur, getPresencesFormateurBySequence } from '../presenceDb'; -import { formateurs, sequences, datesFormation, presences } from '../../drizzle/schema'; -import { eq, and, sql } from 'drizzle-orm'; - -describe('Émargement formateur par QR code', () => { - let testFormateurId: number; - let testSequenceId: number; - let testDateFormationId: number; - const timestamp = Date.now(); - - beforeAll(async () => { - const db = await getDb(); - if (!db) throw new Error('Database not available'); - - // Créer un formateur de test - const formateurResult = await db.insert(formateurs).values({ - nom: `Test Formateur Émargement ${timestamp}`, - email: `test.formateur.emargement.${timestamp}@test.com`, - }); - testFormateurId = Number(formateurResult[0].insertId); - - // Créer une séquence de test - const sequenceResult = await db.insert(sequences).values({ - formationId: 1, - nom: 'Test Séquence Émargement', - lieu: 'Test Lieu', - capaciteMax: 10, - statut: 'ouverte', - publicCible: 'tous', - formateurId: testFormateurId, - }); - testSequenceId = Number(sequenceResult[0].insertId); - - // Créer une date de formation de test - const dateResult = await db.insert(datesFormation).values({ - sequenceId: testSequenceId, - ordre: 1, - dateDebut: new Date(), - dateFin: new Date(Date.now() + 3600000), - }); - testDateFormationId = Number(dateResult[0].insertId); - }); - - afterAll(async () => { - const db = await getDb(); - if (!db) return; - - // Nettoyer les données de test - await db.delete(presences).where(eq(presences.formateurId, testFormateurId)); - await db.delete(datesFormation).where(eq(datesFormation.sequenceId, testSequenceId)); - await db.delete(sequences).where(eq(sequences.id, testSequenceId)); - await db.delete(formateurs).where(eq(formateurs.id, testFormateurId)); - }); - - it('devrait valider la présence du formateur pour le matin', async () => { - const result = await validerPresenceFormateur({ - formateurId: testFormateurId, - sequenceId: testSequenceId, - dateFormationId: testDateFormationId, - periode: 'matin', - modeValidation: 'qrcode', - }); - - expect(result.success).toBe(true); - - // Vérifier que la présence a bien été créée - const db = await getDb(); - if (!db) throw new Error('Database not available'); - - const presenceCreated = await db - .select() - .from(presences) - .where( - and( - eq(presences.formateurId, testFormateurId), - eq(presences.dateFormationId, testDateFormationId), - sql`${presences}.periode = 'matin'`, - sql`${presences}.typeUtilisateur = 'formateur'` - ) - ); - - expect(presenceCreated.length).toBe(1); - expect(presenceCreated[0].typeUtilisateur).toBe('formateur'); - expect(presenceCreated[0].modeValidation).toBe('qrcode'); - }); - - it('devrait valider la présence du formateur pour l\'après-midi', async () => { - const result = await validerPresenceFormateur({ - formateurId: testFormateurId, - sequenceId: testSequenceId, - dateFormationId: testDateFormationId, - periode: 'apres_midi', - modeValidation: 'qrcode', - }); - - expect(result.success).toBe(true); - }); - - it('devrait empêcher la double validation pour la même période', async () => { - await expect( - validerPresenceFormateur({ - formateurId: testFormateurId, - sequenceId: testSequenceId, - dateFormationId: testDateFormationId, - periode: 'matin', - modeValidation: 'qrcode', - }) - ).rejects.toThrow('Présence déjà validée'); - }); - - it('devrait récupérer les présences du formateur pour une séquence', async () => { - const presencesFormateur = await getPresencesFormateurBySequence(testSequenceId); - - expect(presencesFormateur.length).toBeGreaterThanOrEqual(2); // Matin + Après-midi - expect(presencesFormateur.every(p => p.formateurId === testFormateurId)).toBe(true); - }); - - it('devrait stocker la signature tactile du formateur', async () => { - const db = await getDb(); - if (!db) throw new Error('Database not available'); - - // Créer une nouvelle date pour ce test - const dateResult = await db.insert(datesFormation).values({ - sequenceId: testSequenceId, - ordre: 2, - dateDebut: new Date(), - dateFin: new Date(Date.now() + 3600000), - }); - const newDateId = Number(dateResult[0].insertId); - - const signatureUrl = '/uploads/signatures/test-formateur-signature.png'; - const result = await validerPresenceFormateur({ - formateurId: testFormateurId, - sequenceId: testSequenceId, - dateFormationId: newDateId, - periode: 'matin', - modeValidation: 'qrcode', - signatureUrl, - signatureS3Key: 'signatures/test-formateur-signature.png', - }); - - expect(result.success).toBe(true); - - // Vérifier que la signature a été stockée - const presenceCreated = await db - .select() - .from(presences) - .where( - and( - eq(presences.formateurId, testFormateurId), - eq(presences.dateFormationId, newDateId), - sql`${presences}.periode = 'matin'` - ) - ); - - expect(presenceCreated.length).toBe(1); - expect(presenceCreated[0].signatureUrl).toBe(signatureUrl); - - // Nettoyer - await db.delete(datesFormation).where(eq(datesFormation.id, newDateId)); - }); -}); diff --git a/server/exportService.ts b/server/exportService.ts index 182f77d..22a1a10 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -36,16 +36,9 @@ interface ApprenantPresence { dateFormationId: number; periode: 'matin' | 'apres_midi'; signatureUrl: string | null; - typeUtilisateur?: 'apprenant' | 'formateur'; }>; } -interface FormateurPresence { - dateFormationId: number; - periode: 'matin' | 'apres_midi'; - signatureUrl: string | null; -} - interface FeuillePresenceInfo { formationNom: string; sequenceNom: string; @@ -54,7 +47,6 @@ interface FeuillePresenceInfo { publicCible: string; formateur?: string; apprenants: ApprenantPresence[]; - presencesFormateur?: FormateurPresence[]; } /** @@ -309,56 +301,14 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe doc.text('Signatures du formateur :', 14, signatureY); doc.setFont('helvetica', 'normal'); - // Trouver les signatures du formateur pour cette date - const presenceFormateurMatin = feuilleInfo.presencesFormateur?.find( - p => p.dateFormationId === dateInfo.id && p.periode === 'matin' - ); - const presenceFormateurApresMidi = feuilleInfo.presencesFormateur?.find( - p => p.dateFormationId === dateInfo.id && p.periode === 'apres_midi' - ); - // Signature Matin doc.setFontSize(9); doc.text('Matin :', 14, signatureY + 8); - - if (presenceFormateurMatin?.signatureUrl) { - try { - const signaturePath = path.resolve(process.cwd(), presenceFormateurMatin.signatureUrl.substring(1)); - if (fs.existsSync(signaturePath)) { - const signatureBuffer = fs.readFileSync(signaturePath); - const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`; - doc.addImage(signatureBase64, 'PNG', 30, signatureY + 2, 55, 10); - } else { - doc.line(30, signatureY + 8, 90, signatureY + 8); - } - } catch (error) { - console.error('Erreur lors du chargement de la signature formateur matin:', error); - doc.line(30, signatureY + 8, 90, signatureY + 8); - } - } else { - doc.line(30, signatureY + 8, 90, signatureY + 8); - } + doc.line(30, signatureY + 8, 90, signatureY + 8); // Signature Après-midi doc.text('Après-midi :', 110, signatureY + 8); - - if (presenceFormateurApresMidi?.signatureUrl) { - try { - const signaturePath = path.resolve(process.cwd(), presenceFormateurApresMidi.signatureUrl.substring(1)); - if (fs.existsSync(signaturePath)) { - const signatureBuffer = fs.readFileSync(signaturePath); - const signatureBase64 = `data:image/png;base64,${signatureBuffer.toString('base64')}`; - doc.addImage(signatureBase64, 'PNG', 140, signatureY + 2, 55, 10); - } else { - doc.line(140, signatureY + 8, 200, signatureY + 8); - } - } catch (error) { - console.error('Erreur lors du chargement de la signature formateur après-midi:', error); - doc.line(140, signatureY + 8, 200, signatureY + 8); - } - } else { - doc.line(140, signatureY + 8, 200, signatureY + 8); - } + doc.line(140, signatureY + 8, 200, signatureY + 8); }; // Générer une page pour chaque date diff --git a/server/presenceDb-formateur-fix.ts b/server/presenceDb-formateur-fix.ts deleted file mode 100644 index f9a2f78..0000000 --- a/server/presenceDb-formateur-fix.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Correction pour validerPresenceFormateur - à intégrer dans presenceDb.ts ligne 189-256 - -export async function validerPresenceFormateur(params: { - formateurId: number; - sequenceId: number; - dateFormationId?: number; - periode: "matin" | "apres_midi"; - modeValidation: "qrcode" | "manuel"; - commentaire?: string; - signatureUrl?: string; - signatureS3Key?: string; -}) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Si dateFormationId est fourni, vérifier la date - if (params.dateFormationId) { - const dateFormation = await db - .select() - .from(datesFormation) - .where(eq(datesFormation.id, params.dateFormationId)) - .limit(1); - - if (dateFormation.length === 0) { - throw new Error("Date de formation introuvable"); - } - - // Vérifier que la date actuelle est le jour de la formation ou après - const now = new Date(); - const dateDebut = new Date(dateFormation[0].dateDebut); - - // Réinitialiser les heures pour comparer uniquement les dates - now.setHours(0, 0, 0, 0); - dateDebut.setHours(0, 0, 0, 0); - - if (now < dateDebut) { - throw new Error("Vous ne pouvez pas émarger avant la date de formation"); - } - - // Vérifier si la présence existe déjà pour cette période - const presenceExistante = await db - .select() - .from(presences) - .where( - and( - eq(presences.formateurId, params.formateurId), - eq(presences.dateFormationId, params.dateFormationId), - sql`${presences.periode} = ${params.periode}`, - sql`${presences.typeUtilisateur} = 'formateur'` - ) - ); - - if (presenceExistante.length > 0) { - throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); - } - } - - // Créer la présence formateur - await db.insert(presences).values({ - typeUtilisateur: "formateur", - formateurId: params.formateurId, - dateFormationId: params.dateFormationId || null, - periode: params.periode, - heurePresence: new Date(), - modeValidation: params.modeValidation, - commentaire: params.commentaire, - signatureUrl: params.signatureUrl, - signatureS3Key: params.signatureS3Key, - }); - - return { success: true }; -} diff --git a/server/presenceDb.ts b/server/presenceDb.ts index 6a6797f..3b231db 100644 --- a/server/presenceDb.ts +++ b/server/presenceDb.ts @@ -68,6 +68,7 @@ export async function validerPresence(params: { commentaire: params.commentaire, signatureUrl: params.signatureUrl, signatureS3Key: params.signatureS3Key, + dateSigned: params.signatureUrl ? new Date() : undefined, }); return { success: true }; @@ -92,6 +93,7 @@ export async function getPresencesBySequence(sequenceId: number) { commentaire: presences.commentaire, signatureUrl: sql`${presences}.signatureUrl`, signatureS3Key: sql`${presences}.signatureS3Key`, + dateSigned: sql`${presences}.dateSigned`, apprenantId: apprenants.id, apprenantNom: apprenants.nom, apprenantPrenom: apprenants.prenom, @@ -126,6 +128,7 @@ export async function getPresencesByInscription(inscriptionId: number) { commentaire: presences.commentaire, signatureUrl: sql`${presences}.signatureUrl`, signatureS3Key: sql`${presences}.signatureS3Key`, + dateSigned: sql`${presences}.dateSigned`, dateDebut: datesFormation.dateDebut, dateFin: datesFormation.dateFin, }) @@ -182,106 +185,3 @@ export async function supprimerPresence(presenceId: number) { return { success: true }; } - -/** - * Valider la présence d'un formateur (par QR code ou manuellement) - */ -export async function validerPresenceFormateur(params: { - formateurId: number; - sequenceId: number; - dateFormationId?: number; - periode: "matin" | "apres_midi"; - modeValidation: "qrcode" | "manuel"; - commentaire?: string; - signatureUrl?: string; - signatureS3Key?: string; -}) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Récupérer la date de formation - const dateFormation = await db - .select() - .from(datesFormation) - .where(eq(datesFormation.id, params.dateFormationId)) - .limit(1); - - if (dateFormation.length === 0) { - throw new Error("Date de formation introuvable"); - } - - // Vérifier que la date actuelle est le jour de la formation ou après - const now = new Date(); - const dateDebut = new Date(dateFormation[0].dateDebut); - - // Réinitialiser les heures pour comparer uniquement les dates - now.setHours(0, 0, 0, 0); - dateDebut.setHours(0, 0, 0, 0); - - if (now < dateDebut) { - throw new Error("Vous ne pouvez pas émarger avant la date de formation"); - } - - // Vérifier si la présence existe déjà pour cette période - const presenceExistante = await db - .select() - .from(presences) - .where( - and( - eq(presences.formateurId, params.formateurId), - eq(presences.dateFormationId, params.dateFormationId), - sql`${presences.periode} = ${params.periode}`, - sql`${presences.typeUtilisateur} = 'formateur'` - ) - ); - - if (presenceExistante.length > 0) { - throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); - } - - // Créer la présence formateur - await db.insert(presences).values({ - typeUtilisateur: "formateur", - formateurId: params.formateurId, - dateFormationId: params.dateFormationId, - periode: params.periode, - heurePresence: new Date(), - modeValidation: params.modeValidation, - commentaire: params.commentaire, - signatureUrl: params.signatureUrl, - signatureS3Key: params.signatureS3Key, - }); - - return { success: true }; -} - -/** - * Récupérer les présences du formateur pour une séquence - */ -export async function getPresencesFormateurBySequence(sequenceId: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - const result = await db - .select({ - presenceId: presences.id, - formateurId: presences.formateurId, - dateFormationId: presences.dateFormationId, - periode: presences.periode, - heurePresence: presences.heurePresence, - modeValidation: presences.modeValidation, - commentaire: presences.commentaire, - signatureUrl: sql`${presences}.signatureUrl`, - signatureS3Key: sql`${presences}.signatureS3Key`, - }) - .from(presences) - .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) - .where( - and( - eq(datesFormation.sequenceId, sequenceId), - sql`${presences}.typeUtilisateur = 'formateur'` - ) - ); - - return result; -} diff --git a/server/presenceDb.ts.backup b/server/presenceDb.ts.backup deleted file mode 100644 index 6a6797f..0000000 --- a/server/presenceDb.ts.backup +++ /dev/null @@ -1,287 +0,0 @@ -import { eq, and, sql } from "drizzle-orm"; -import { presences, inscriptions, datesFormation, sequences, apprenants } from "../drizzle/schema"; -import { getDb } from "./db"; - -/** - * Valider la présence d'un apprenant (par QR code ou manuellement) - */ -export async function validerPresence(params: { - inscriptionId: number; - dateFormationId: number; - periode: "matin" | "apres_midi"; - modeValidation: "qrcode" | "manuel"; - validateurId?: number; - commentaire?: string; - signatureUrl?: string; - signatureS3Key?: string; -}) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Récupérer la date de formation - const dateFormation = await db - .select() - .from(datesFormation) - .where(eq(datesFormation.id, params.dateFormationId)) - .limit(1); - - if (dateFormation.length === 0) { - throw new Error("Date de formation introuvable"); - } - - // Vérifier que la date actuelle est le jour de la formation ou après - const now = new Date(); - const dateDebut = new Date(dateFormation[0].dateDebut); - - // Réinitialiser les heures pour comparer uniquement les dates - now.setHours(0, 0, 0, 0); - dateDebut.setHours(0, 0, 0, 0); - - if (now < dateDebut) { - throw new Error("Vous ne pouvez pas émarger avant la date de formation"); - } - - // Vérifier si la présence existe déjà pour cette période - const presenceExistante = await db - .select() - .from(presences) - .where( - and( - eq(presences.inscriptionId, params.inscriptionId), - eq(presences.dateFormationId, params.dateFormationId), - sql`${presences.periode} = ${params.periode}` - ) - ); - - if (presenceExistante.length > 0) { - throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); - } - - // Créer la présence - await db.insert(presences).values({ - inscriptionId: params.inscriptionId, - dateFormationId: params.dateFormationId, - periode: params.periode, - heurePresence: new Date(), - modeValidation: params.modeValidation, - validateurId: params.validateurId, - commentaire: params.commentaire, - signatureUrl: params.signatureUrl, - signatureS3Key: params.signatureS3Key, - }); - - return { success: true }; -} - -/** - * Récupérer toutes les présences pour une séquence - */ -export async function getPresencesBySequence(sequenceId: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - const result = await db - .select({ - presenceId: presences.id, - inscriptionId: presences.inscriptionId, - dateFormationId: presences.dateFormationId, - periode: presences.periode, - heurePresence: presences.heurePresence, - modeValidation: presences.modeValidation, - validateurId: presences.validateurId, - commentaire: presences.commentaire, - signatureUrl: sql`${presences}.signatureUrl`, - signatureS3Key: sql`${presences}.signatureS3Key`, - apprenantId: apprenants.id, - apprenantNom: apprenants.nom, - apprenantPrenom: apprenants.prenom, - apprenantEmail: apprenants.email, - dateDebut: datesFormation.dateDebut, - dateFin: datesFormation.dateFin, - }) - .from(presences) - .innerJoin(inscriptions, eq(presences.inscriptionId, inscriptions.id)) - .innerJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)) - .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) - .where(eq(datesFormation.sequenceId, sequenceId)); - - return result; -} - -/** - * Récupérer les présences pour une inscription spécifique - */ -export async function getPresencesByInscription(inscriptionId: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - const result = await db - .select({ - presenceId: presences.id, - dateFormationId: presences.dateFormationId, - periode: presences.periode, - heurePresence: presences.heurePresence, - modeValidation: presences.modeValidation, - validateurId: presences.validateurId, - commentaire: presences.commentaire, - signatureUrl: sql`${presences}.signatureUrl`, - signatureS3Key: sql`${presences}.signatureS3Key`, - dateDebut: datesFormation.dateDebut, - dateFin: datesFormation.dateFin, - }) - .from(presences) - .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) - .where(eq(presences.inscriptionId, inscriptionId)); - - return result; -} - -/** - * Vérifier si toutes les présences sont validées pour une inscription - */ -export async function checkAllPresencesValidated(inscriptionId: number): Promise { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Récupérer l'inscription avec la séquence - const inscription = await db - .select({ - sequenceId: inscriptions.sequenceId, - }) - .from(inscriptions) - .where(eq(inscriptions.id, inscriptionId)) - .limit(1); - - if (inscription.length === 0) { - throw new Error("Inscription not found"); - } - - // Compter le nombre de dates de formation pour cette séquence - const datesCount = await db - .select({ count: datesFormation.id }) - .from(datesFormation) - .where(eq(datesFormation.sequenceId, inscription[0].sequenceId)); - - // Compter le nombre de présences validées pour cette inscription - const presencesCount = await db - .select({ count: presences.id }) - .from(presences) - .where(eq(presences.inscriptionId, inscriptionId)); - - return presencesCount.length === datesCount.length; -} - -/** - * Supprimer une présence - */ -export async function supprimerPresence(presenceId: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - await db.delete(presences).where(eq(presences.id, presenceId)); - - return { success: true }; -} - -/** - * Valider la présence d'un formateur (par QR code ou manuellement) - */ -export async function validerPresenceFormateur(params: { - formateurId: number; - sequenceId: number; - dateFormationId?: number; - periode: "matin" | "apres_midi"; - modeValidation: "qrcode" | "manuel"; - commentaire?: string; - signatureUrl?: string; - signatureS3Key?: string; -}) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Récupérer la date de formation - const dateFormation = await db - .select() - .from(datesFormation) - .where(eq(datesFormation.id, params.dateFormationId)) - .limit(1); - - if (dateFormation.length === 0) { - throw new Error("Date de formation introuvable"); - } - - // Vérifier que la date actuelle est le jour de la formation ou après - const now = new Date(); - const dateDebut = new Date(dateFormation[0].dateDebut); - - // Réinitialiser les heures pour comparer uniquement les dates - now.setHours(0, 0, 0, 0); - dateDebut.setHours(0, 0, 0, 0); - - if (now < dateDebut) { - throw new Error("Vous ne pouvez pas émarger avant la date de formation"); - } - - // Vérifier si la présence existe déjà pour cette période - const presenceExistante = await db - .select() - .from(presences) - .where( - and( - eq(presences.formateurId, params.formateurId), - eq(presences.dateFormationId, params.dateFormationId), - sql`${presences.periode} = ${params.periode}`, - sql`${presences.typeUtilisateur} = 'formateur'` - ) - ); - - if (presenceExistante.length > 0) { - throw new Error(`Présence déjà validée pour ${params.periode === 'matin' ? 'le matin' : 'l\'après-midi'}`); - } - - // Créer la présence formateur - await db.insert(presences).values({ - typeUtilisateur: "formateur", - formateurId: params.formateurId, - dateFormationId: params.dateFormationId, - periode: params.periode, - heurePresence: new Date(), - modeValidation: params.modeValidation, - commentaire: params.commentaire, - signatureUrl: params.signatureUrl, - signatureS3Key: params.signatureS3Key, - }); - - return { success: true }; -} - -/** - * Récupérer les présences du formateur pour une séquence - */ -export async function getPresencesFormateurBySequence(sequenceId: number) { - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - const result = await db - .select({ - presenceId: presences.id, - formateurId: presences.formateurId, - dateFormationId: presences.dateFormationId, - periode: presences.periode, - heurePresence: presences.heurePresence, - modeValidation: presences.modeValidation, - commentaire: presences.commentaire, - signatureUrl: sql`${presences}.signatureUrl`, - signatureS3Key: sql`${presences}.signatureS3Key`, - }) - .from(presences) - .innerJoin(datesFormation, eq(presences.dateFormationId, datesFormation.id)) - .where( - and( - eq(datesFormation.sequenceId, sequenceId), - sql`${presences}.typeUtilisateur = 'formateur'` - ) - ); - - return result; -} diff --git a/server/qrCodeGenerator.ts b/server/qrCodeGenerator.ts index 57f60a4..0d081a3 100644 --- a/server/qrCodeGenerator.ts +++ b/server/qrCodeGenerator.ts @@ -38,35 +38,6 @@ export async function generateQRCodeDataURL(token: string): Promise { } } -/** - * Générer un QR code formateur en base64 à partir d'un token - * @param token Le token unique de la séquence - * @returns Une image QR code en base64 (data URL) pour l'émargement formateur - */ -export async function generateQRCodeFormateurDataURL(token: string): Promise { - // URL complète pour scanner le QR code formateur - const parametres = await getParametres(); - const url = `${parametres.urlPublique}/emargement-formateur/${token}`; - - try { - const qrCodeDataURL = await QRCode.toDataURL(url, { - errorCorrectionLevel: "H", - type: "image/png", - width: 400, - margin: 2, - color: { - dark: "#000000", - light: "#FFFFFF", - }, - }); - - return qrCodeDataURL; - } catch (error) { - console.error("Erreur lors de la génération du QR code formateur:", error); - throw new Error("Impossible de générer le QR code formateur"); - } -} - /** * Générer un QR code en buffer PNG * @param token Le token unique de la séquence diff --git a/server/routers.ts b/server/routers.ts index 11fd55e..881ea28 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1156,7 +1156,7 @@ export const appRouter = router({ } // Récupérer toutes les présences avec signatures pour cette séquence - const { getPresencesBySequence, getPresencesFormateurBySequence } = await import("./presenceDb"); + const { getPresencesBySequence } = await import("./presenceDb"); const presences = await getPresencesBySequence(input.sequenceId); const data = inscriptions @@ -1180,9 +1180,6 @@ export const appRouter = router({ }; }); - // Récupérer les présences du formateur - const presencesFormateur = await getPresencesFormateurBySequence(input.sequenceId); - const buffer = await generateFeuillePresence({ formationNom: formation.nom, sequenceNom: sequence.nom, @@ -1196,11 +1193,6 @@ export const appRouter = router({ publicCible: sequence.publicCible, formateur: formateurNom, apprenants: data, - presencesFormateur: presencesFormateur.map(p => ({ - dateFormationId: p.dateFormationId, - periode: p.periode as 'matin' | 'apres_midi', - signatureUrl: p.signatureUrl, - })), }); return { @@ -2517,239 +2509,70 @@ export const appRouter = router({ }; }), - // Générer un QR code formateur pour une séquence - generateQRCodeFormateur: protectedProcedure - .input(z.object({ - sequenceId: z.number(), - })) - .mutation(async ({ input, ctx }) => { - const { generateQRToken, generateQRCodeFormateurDataURL } = await import("./qrCodeGenerator"); - const { getDb } = await import("./db"); - const { sequences } = await import("../drizzle/schema"); - const { eq } = await import("drizzle-orm"); - - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Vérifier que la séquence existe - const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1); - if (sequence.length === 0) { - throw new Error("Séquence introuvable"); - } - - // Générer un nouveau token si nécessaire - let token = sequence[0].qrCodeToken; - if (!token) { - token = generateQRToken(); - await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId)); - } - - // Générer le QR code formateur - const qrCodeDataURL = await generateQRCodeFormateurDataURL(token); - - return { - token, - qrCodeDataURL, - }; - }), - - // Récupérer les informations de la séquence par token (pour formateurs) - getSequenceByToken: publicProcedure - .input(z.object({ - token: z.string(), - })) - .query(async ({ input }) => { - const { getDb, getDatesBySequence } = await import("./db"); - const { sequences } = await import("../drizzle/schema"); - const { eq } = await import("drizzle-orm"); - - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Récupérer la séquence par token - const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); - if (sequence.length === 0) { - throw new Error("QR code invalide"); - } - - // Récupérer les dates de la séquence - const dates = await getDatesBySequence(sequence[0].id); - - return { - id: sequence[0].id, - nom: sequence[0].nom, - lieu: sequence[0].lieu, - dates, - }; - }), - - // Valider la présence d'un formateur (QR code dédié) - validerFormateur: publicProcedure - .input(z.object({ - token: z.string(), - email: z.string().email(), - periode: z.enum(["matin", "apres_midi"]), - signatureDataUrl: z.string(), - })) - .mutation(async ({ input }) => { - const { getDb } = await import("./db"); - const { formateurs, sequences, datesFormation } = await import("../drizzle/schema"); - const { eq } = await import("drizzle-orm"); - const { validerPresenceFormateur } = await import("./presenceDb"); - const { storagePut } = await import("./storage"); - const crypto = await import("crypto"); - const path = await import("path"); - const fs = await import("fs/promises"); - - const db = await getDb(); - if (!db) throw new Error("Database not available"); - - // Vérifier que l'email correspond à un formateur - const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1); - if (formateurData.length === 0) { - throw new Error("Aucun formateur trouvé avec cet email"); - } - - // Vérifier le token et récupérer la séquence - const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); - if (sequence.length === 0) { - throw new Error("QR code invalide"); - } - - // Récupérer la première date de la séquence - const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequence[0].id)).orderBy(datesFormation.dateDebut).limit(1); - if (dates.length === 0) { - throw new Error("Aucune date de formation trouvée pour cette séquence"); - } - - // Stocker la signature localement - const formationId = sequence[0].formationId; - const sequenceId = sequence[0].id; - const uploadsDir = path.join(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`); - await fs.mkdir(uploadsDir, { recursive: true }); - - const randomSuffix = crypto.randomBytes(8).toString("hex"); - const fileName = `signature-formateur-${formateurData[0].id}-${dates[0].id}-${randomSuffix}.png`; - const filePath = path.join(uploadsDir, fileName); - - const base64Data = input.signatureDataUrl.split(",")[1]; - const buffer = Buffer.from(base64Data, "base64"); - await fs.writeFile(filePath, buffer); - - const signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`; - const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`; - - // Valider la présence formateur - await validerPresenceFormateur({ - formateurId: formateurData[0].id, - sequenceId: sequence[0].id, - dateFormationId: dates[0].id, - periode: input.periode, - modeValidation: "qrcode", - signatureUrl, - signatureS3Key, - }); - - return { success: true }; - }), - // Valider une présence (scan QR code ou manuel) valider: publicProcedure .input(z.object({ token: z.string().optional(), - email: z.string().email(), - inscriptionId: z.number().optional(), - dateFormationId: z.number().optional(), - dateId: z.number().optional(), // Alias pour dateFormationId + inscriptionId: z.number(), + dateFormationId: z.number(), periode: z.enum(["matin", "apres_midi"]), - modeValidation: z.enum(["qrcode", "manuel"]).optional().default("qrcode"), + modeValidation: z.enum(["qrcode", "manuel"]), validateurId: z.number().optional(), commentaire: z.string().optional(), signatureDataUrl: z.string().optional(), })) .mutation(async ({ input }) => { - const { validerPresence, validerPresenceFormateur } = await import("./presenceDb"); + const { validerPresence } = await import("./presenceDb"); const { getDb } = await import("./db"); - const { sequences, inscriptions, formateurs } = await import("../drizzle/schema"); + const { sequences, inscriptions } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const db = await getDb(); if (!db) throw new Error("Database not available"); - // Détecter si l'utilisateur est un formateur - const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1); - const isFormateur = formateurData.length > 0; - // Si mode QR code, vérifier le token - let sequenceId: number | undefined; - let autoDateFormationId: number | undefined; if (input.modeValidation === "qrcode" && input.token) { - // Vérifier que le token correspond à une séquence - const { datesFormation } = await import("../drizzle/schema"); - const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1); - if (sequence.length === 0) { + + // Récupérer l'inscription pour vérifier la séquence + const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (inscription.length === 0) { + throw new Error("Inscription introuvable"); + } + + // Vérifier que le token correspond à la séquence + const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1); + if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) { throw new Error("QR code invalide"); } - sequenceId = sequence[0].id; - - // Si formateur, récupérer automatiquement la première date de la séquence - if (isFormateur && !input.dateFormationId && !input.dateId) { - const dates = await db.select().from(datesFormation).where(eq(datesFormation.sequenceId, sequenceId)).orderBy(datesFormation.dateDebut).limit(1); - if (dates.length > 0) { - autoDateFormationId = dates[0].id; - } - } - - // Si apprenant, vérifier l'inscription - if (!isFormateur && input.inscriptionId) { - const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); - if (inscription.length === 0) { - throw new Error("Inscription introuvable"); - } - if (inscription[0].sequenceId !== sequenceId) { - throw new Error("QR code invalide pour cette inscription"); - } - } } // Stocker la signature localement si fournie let signatureUrl: string | undefined; let signatureS3Key: string | undefined; - let finalSequenceId = sequenceId; - if (input.signatureDataUrl) { const fs = await import("fs"); const path = await import("path"); const crypto = await import("crypto"); - // Déterminer la séquence - if (!finalSequenceId) { - if (input.inscriptionId) { - const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); - if (inscriptionData.length === 0) throw new Error("Inscription introuvable"); - finalSequenceId = inscriptionData[0].sequenceId; - } else { - throw new Error("Impossible de déterminer la séquence"); - } - } + // Récupérer l'inscription pour obtenir la séquence + const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (inscriptionData.length === 0) throw new Error("Inscription introuvable"); - const sequenceData = await db.select().from(sequences).where(eq(sequences.id, finalSequenceId)).limit(1); + const sequenceData = await db.select().from(sequences).where(eq(sequences.id, inscriptionData[0].sequenceId)).limit(1); if (sequenceData.length === 0) throw new Error("Séquence introuvable"); const formationId = sequenceData[0].formationId; + const sequenceId = sequenceData[0].id; // Créer la structure de dossiers - const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${finalSequenceId}`); + const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Générer un nom de fichier unique const randomSuffix = crypto.randomBytes(8).toString("hex"); - const userType = isFormateur ? "formateur" : "apprenant"; - const userId = isFormateur ? formateurData[0].id : input.inscriptionId; - const dateRef = input.dateFormationId || input.dateId || "no-date"; - const fileName = `signature-${userType}-${userId}-${dateRef}-${randomSuffix}.png`; + const fileName = `signature-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`; const filePath = path.join(uploadsDir, fileName); // Convertir le data URL en buffer et sauvegarder @@ -2758,77 +2581,57 @@ export const appRouter = router({ fs.writeFileSync(filePath, buffer); // Générer l'URL publique - signatureUrl = `/uploads/signatures/${formationId}-${finalSequenceId}/${fileName}`; - signatureS3Key = `signatures/${formationId}-${finalSequenceId}/${fileName}`; + signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`; + signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`; } - let result; - if (isFormateur) { - // Valider la présence du formateur - if (!finalSequenceId) throw new Error("Impossible de déterminer la séquence"); - result = await validerPresenceFormateur({ - formateurId: formateurData[0].id, - sequenceId: finalSequenceId, - dateFormationId: input.dateFormationId || input.dateId || undefined, - periode: input.periode, - modeValidation: input.modeValidation || "qrcode", - commentaire: input.commentaire, - signatureUrl, - signatureS3Key, - }); - } else { - // Valider la présence de l'apprenant - if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant"); - const dateFormId = input.dateFormationId || input.dateId; - if (!dateFormId) throw new Error("Date de formation requise pour un apprenant"); - result = await validerPresence({ - inscriptionId: input.inscriptionId, - dateFormationId: dateFormId, - periode: input.periode, - modeValidation: input.modeValidation || "qrcode", - validateurId: input.validateurId, - commentaire: input.commentaire, - signatureUrl, - signatureS3Key, - }); + const result = await validerPresence({ + inscriptionId: input.inscriptionId, + dateFormationId: input.dateFormationId, + periode: input.periode, + modeValidation: input.modeValidation, + validateurId: input.validateurId, + commentaire: input.commentaire, + signatureUrl, + signatureS3Key, + }); - // Vérifier si toutes les présences sont validées - const { checkAllPresencesValidated } = await import("./presenceDb"); - const allValidated = await checkAllPresencesValidated(input.inscriptionId); + // Vérifier si toutes les présences sont validées + const { checkAllPresencesValidated } = await import("./presenceDb"); + const allValidated = await checkAllPresencesValidated(input.inscriptionId); - // Si toutes les présences sont validées, générer l'attestation automatiquement - if (allValidated) { - try { - const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService"); - - // Générer le PDF - const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); - - // Récupérer les données de l'inscription - const db = await getDb(); - if (db) { - const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); - if (inscriptionData.length > 0) { - // Enregistrer l'attestation en base - await enregistrerAttestation( - input.inscriptionId, - inscriptionData[0].apprenantId, - inscriptionData[0].sequenceId, - s3Key, - pdfUrl - ); - } + // Si toutes les présences sont validées, générer l'attestation automatiquement + if (allValidated) { + try { + const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService"); + + // Générer le PDF + const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); + + // Récupérer les données de l'inscription + const db = await getDb(); + if (db) { + const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); + if (inscriptionData.length > 0) { + // Enregistrer l'attestation en base + await enregistrerAttestation( + input.inscriptionId, + inscriptionData[0].apprenantId, + inscriptionData[0].sequenceId, + s3Key, + pdfUrl + ); } - - return { - ...result, - attestationGenerated: true, - attestationUrl: pdfUrl, - }; - } catch (error) { - console.error("Erreur lors de la génération de l'attestation:", error); - // Ne pas bloquer la validation de présence si l'attestation échoue } + + return { + ...result, + attestationGenerated: true, + attestationUrl: pdfUrl, + }; + } catch (error) { + console.error("Erreur lors de la génération de l'attestation:", error); + // Ne pas bloquer la validation de présence si l'attestation échoue } } diff --git a/todo.md b/todo.md index 3ad8eea..fafc099 100644 --- a/todo.md +++ b/todo.md @@ -1501,56 +1501,4 @@ - [x] Empêcher l'émargement avant la date de formation concernée (vérification backend) -## Émargement formateur par QR code avec signature tactile - -- [x] Modifier le schéma de base de données (table presences) pour distinguer formateurs/apprenants et stocker les signatures tactiles -- [x] Créer une interface de signature tactile pour les formateurs accessible après scan du QR code (interface existante réutilisée) -- [x] Implémenter la logique backend pour détecter automatiquement si l'utilisateur est formateur ou apprenant (via email) -- [x] Intégrer les signatures tactiles formateur dans la génération PDF de la feuille de présence -- [x] Afficher le QR code dans l'espace formateur pour faciliter l'émargement -- [x] Tester le flux complet (scan QR, signature, intégration PDF) -- [x] Prêt pour le déploiement sur le VPS de production - -## Bug : Erreur lors de la modification d'un utilisateur - -- [x] Diagnostiquer l'erreur "formateurId" dans la table users -- [x] Ajouter la colonne formateurId manquante à la table users sur le VPS -- [x] Vérifier que la modification d'utilisateur fonctionne correctement - -## Bug : Signature formateur bloquée sur la saisie d'email (smartphone) - -- [x] Analyser le code EmargementScan.tsx pour identifier pourquoi le bouton "Continuer" ne fonctionne pas -- [x] Corriger le bug de validation du formulaire email (ajout détection formateur/apprenant) -- [ ] Tester sur smartphone -- [x] Déployer la correction sur le VPS - -## Bug : Chargement infini en mode formateur (émargement QR code) - -- [x] Diagnostiquer pourquoi getSequenceByToken ne retourne pas de données -- [x] Vérifier que le token QR code est bien passé dans l'URL -- [x] Corriger le problème de récupération des données de séquence -- [ ] Tester sur smartphone -- [x] Déployer la correction sur le VPS (redémarrage PM2 complet effectué) - -## Correction finale : Simplifier l'approche sans getSequenceByToken - -- [x] Retirer getSequenceByToken et utiliser la logique existante de validation -- [x] Modifier EmargementScan.tsx pour détecter le formateur sans appel API supplémentaire -- [ ] Vider tous les caches (Nginx, navigateur) -- [ ] Tester le flux complet -- [x] Déployer sur le VPS - -## Bug : Erreur de validation backend pour les formateurs - -- [ ] Modifier le schéma de validation de `presences.valider` pour rendre `inscriptionId` et `dateFormationId` optionnels -- [ ] Adapter la logique backend pour gérer les formateurs sans ces champs -- [ ] Déployer la correction sur le VPS - -## Solution finale : QR code dédié pour les formateurs - -- [x] Créer la page `/emargement-formateur/:token` avec formulaire simplifié (email + période + signature) -- [x] Créer la procédure backend `presences.validerFormateur` dédiée -- [x] Ajouter un bouton "QR Code Formateur" dans FormateurEmargement.tsx -- [x] Créer la procédure `generateQRCodeFormateur` pour générer l'URL formateur -- [ ] Tester le flux complet -- [x] Déployer sur le VPS +- [ ] Permettre aux formateurs d'émarger par QR code (matin et après-midi) avec signature tactile (en cours - à finaliser lors d'une prochaine session)