Compare commits

...

10 Commits

Author SHA1 Message Date
Manus
827c9ec41e Checkpoint: Ajout de Google Gemini (gemini-2.0-flash) comme 3ème moteur IA : schéma DB mis à jour (enum aiProvider + colonne geminiApiKey dans userSettings et importSettings), llm.ts mis à jour avec routing vers l'API Google AI Studio (compatible OpenAI), fenêtre de configuration IA mise à jour avec 3 cartes (Mistral / Gemini / Manus) et champ clé API Gemini. 2026-07-27 15:55:07 +00:00
Manus
46a61fca0e Checkpoint: Ventilations, automatismes, services et signatures sont maintenant globaux (sans filtre userId). Doublons supprimés en production. 2026-07-13 10:21:46 -04:00
Manus
1951b9b9d1 Checkpoint: Les admins voient désormais toutes les factures (tous userId) : getById, update, delete, validateBAP, devalidateBAP, validateBAPBulk, reprocessSelected, regenerateBapPdf, search, exportToExcel, exportToPdf, exportSFTP, bapHistory.delete/regenerate — tous corrigés pour les admins. 2026-07-13 08:13:49 -04:00
Manus
a994ec2e61 Checkpoint: Fix: colonne azureAdId agrandie à VARCHAR(128) pour accepter les identifiants Azure AD composés (objectId.tenantId). Migration appliquée en production. 2026-07-07 06:20:33 -04:00
Manus
8ee10602d3 Checkpoint: Ajout de la connexion OAuth2 Microsoft 365 (Azure AD) : route callback /api/auth/azure/callback, bouton Microsoft sur la page de login, variables d'environnement Azure AD configurées 2026-07-07 05:54:46 -04:00
Manus
d4c9bf635a Checkpoint: Fix: la procédure deleteAll des importLogs permet maintenant aux admins de supprimer tous les logs (pas seulement les leurs). Correction de deleteAllImportLogs dans db.ts pour accepter userId=null (suppression totale). 2026-06-17 06:20:40 -04:00
Manus
f7d3f6d5b3 Checkpoint: Ajout colonne "Source import" (Email / Dossier / Fichier) dans l'historique des imports : schéma DB migré, backend mis à jour (routers.ts, emailImportService.ts, folderImportService.ts), frontend History.tsx avec badges colorés dans le tableau et le dialog de détails. 2026-06-17 06:11:46 -04:00
Manus
b97efb5c57 Checkpoint: Fix: bouton de téléchargement PDF BAP pour factures validées sans PDF (NOVRH VF11521, EVOLUCARE ET098857). Ajout procédure regenerateBapPdf côté serveur + bouton orange RefreshCw côté frontend pour régénérer le PDF à la volée. Déployé en recette et production. 2026-06-17 03:41:18 -04:00
Manus
756f8ab392 fix: stabiliser validatedInvoiceIds avec useMemo pour bouton téléchargement BAP 2026-06-16 12:29:58 -04:00
Manus
1a09c98ef5 fix: exportStatus mis à jour à 'exported' après validation BAP (unitaire et en masse) 2026-06-16 12:18:40 -04:00
22 changed files with 7027 additions and 48 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ temp/
*.db
*.sqlite
*.sqlite3
.project-config.json

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.bd4328423008 --database fo4DRyBgjsuiigFAgNLuWm --execute \nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 287
}

View File

@@ -23,6 +23,9 @@ import {
RotateCcw,
Package,
X,
Upload,
FolderOpen,
Mail,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@@ -307,6 +310,7 @@ export default function History() {
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">Fichier importé</TableHead>
<TableHead className="font-semibold">Date d'import</TableHead>
<TableHead className="font-semibold text-center">Source</TableHead>
<TableHead className="font-semibold text-center">Détectées</TableHead>
<TableHead className="font-semibold text-center">Importées</TableHead>
<TableHead className="font-semibold text-center">Doublons</TableHead>
@@ -326,6 +330,24 @@ export default function History() {
<TableCell className="text-sm text-gray-600">
{new Date(log.importedAt).toLocaleString("fr-FR")}
</TableCell>
<TableCell className="text-center">
{log.importSource === "email" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 border border-blue-200">
<Mail className="h-3 w-3" />
Email
</span>
) : log.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
<FolderOpen className="h-3 w-3" />
Dossier
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-200">
<Upload className="h-3 w-3" />
Fichier
</span>
)}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="font-mono">
{log.totalInvoicesDetected}
@@ -408,6 +430,18 @@ export default function History() {
<p className="text-xs text-gray-500 font-medium mb-1">Date d'import</p>
<p className="text-sm font-semibold">{new Date(selectedLog.importedAt).toLocaleString("fr-FR")}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-500 font-medium mb-1">Source import</p>
<p className="text-sm font-semibold">
{selectedLog.importSource === "email" ? (
<span className="inline-flex items-center gap-1 text-blue-700"><Mail className="h-4 w-4" /> Email</span>
) : selectedLog.importSource === "folder" ? (
<span className="inline-flex items-center gap-1 text-purple-700"><FolderOpen className="h-4 w-4" /> Dossier</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-700"><Upload className="h-4 w-4" /> Fichier</span>
)}
</p>
</div>
</div>
{/* Statistiques */}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -155,7 +155,12 @@ export default function InvoicesBAP() {
// Filter for BAP invoices only (Abonnement = NON, isSubscription = 0)
const invoices = allInvoices?.filter(inv => inv.isSubscription === 0);
// IDs des factures déjà validées BAP (pour charger leurs pdfUrl depuis bapHistory)
const validatedInvoiceIds = (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id);
// Stabilisé avec useMemo pour éviter les re-renders infinis (anti-pattern tRPC)
const validatedInvoiceIds = useMemo(
() => (invoices || []).filter(inv => inv.bapValidated === 1).map(inv => inv.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[invoices?.map(i => i.id).join(',')]
);
const { data: persistedBapPdfUrls } = trpc.invoices.getBapPdfUrls.useQuery(
{ invoiceIds: validatedInvoiceIds },
{ enabled: validatedInvoiceIds.length > 0 }
@@ -284,6 +289,21 @@ export default function InvoicesBAP() {
},
});
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
@@ -995,11 +1015,14 @@ export default function InvoicesBAP() {
<Button
size="sm"
variant="outline"
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed"
title="PDF non disponible"
disabled
className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible — cliquer pour régénérer"
disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
>
<Download className="h-4 w-4" />
{regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -10,9 +10,33 @@ import { toast } from "sonner";
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/itinova-logo_2a2ba00a.jpg";
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/fo4DRyBgjsuiigFAgNLuWm/santinova-logo_5ae0d248.webp";
// Logo Microsoft SVG officiel
function MicrosoftLogo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
);
}
export default function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [azureLoading, setAzureLoading] = useState(false);
// Afficher les erreurs transmises via query param (ex: depuis le callback Azure)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
if (error) {
toast.error(decodeURIComponent(error));
// Nettoyer l'URL
window.history.replaceState({}, "", "/login");
}
}, []);
const loginMutation = trpc.auth.loginLocal.useMutation({
onSuccess: () => {
@@ -24,11 +48,36 @@ export default function Login() {
},
});
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, {
enabled: false,
retry: false,
});
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
const handleLocalLogin = (e: React.FormEvent) => {
e.preventDefault();
loginMutation.mutate({ email: username, password });
};
const handleMicrosoftLogin = async () => {
setAzureLoading(true);
try {
const result = await azureLoginQuery.refetch();
if (result.data?.url) {
window.location.href = result.data.url;
} else {
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
setAzureLoading(false);
}
} catch {
toast.error("Erreur lors de la connexion Microsoft");
setAzureLoading(false);
}
};
const azureAvailable = azureAvailableQuery.data?.available ?? false;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 p-4">
<div className="w-full max-w-sm flex flex-col items-center gap-6">
@@ -50,6 +99,35 @@ export default function Login() {
<p className="text-sm text-slate-500 mt-1">Connectez-vous pour accéder à l'application</p>
</div>
{/* Bouton Microsoft 365 */}
{azureAvailable && (
<>
<Button
type="button"
variant="outline"
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
onClick={handleMicrosoftLogin}
disabled={azureLoading}
>
{azureLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MicrosoftLogo />
)}
Se connecter avec Microsoft 365
</Button>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-400">ou</span>
</div>
</div>
</>
)}
<form onSubmit={handleLocalLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Identifiant</Label>

View File

@@ -677,12 +677,14 @@ export default function Settings() {
const [sftpAutoExport, setSftpAutoExport] = useState(false);
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
const [aiProvider, setAiProvider] = useState<"mistral" | "manus">("mistral");
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
const [mistralApiKey, setMistralApiKey] = useState("");
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [showMistralKey, setShowMistralKey] = useState(false);
const [showManusKey, setShowManusKey] = useState(false);
const [showGeminiKey, setShowGeminiKey] = useState(false);
useEffect(() => {
if (settings) {
@@ -706,6 +708,7 @@ export default function Settings() {
setMistralApiKey((settings as any).mistralApiKey || "");
setManusForgeApiKey((settings as any).manusForgeApiKey || "");
setManusForgeApiUrl((settings as any).manusForgeApiUrl || "");
setGeminiApiKey((settings as any).geminiApiKey || "");
}
}, [settings]);
@@ -754,6 +757,7 @@ export default function Settings() {
mistralApiKey,
manusForgeApiKey,
manusForgeApiUrl,
geminiApiKey,
});
};
@@ -853,7 +857,7 @@ export default function Settings() {
<CardContent className="space-y-6 pt-6">
{/* Provider selector */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<button
type="button"
onClick={() => setAiProvider("mistral")}
@@ -880,6 +884,36 @@ export default function Settings() {
)}
</button>
{/* Google Gemini */}
<button
type="button"
onClick={() => setAiProvider("gemini")}
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
aiProvider === "gemini"
? "border-green-500 bg-green-50 dark:bg-green-950/20 shadow-md"
: "border-muted hover:border-green-300 bg-background"
}`}
>
{aiProvider === "gemini" && (
<div className="absolute top-2 right-2">
<CheckCircle className="w-5 h-5 text-green-500" />
</div>
)}
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-xl">
<svg className="w-8 h-8" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#4285F4"/>
<path d="M12 6l1.5 4.5H18l-3.75 2.75L15.75 18 12 15.25 8.25 18l1.5-4.75L6 10.5h4.5L12 6z" fill="white"/>
</svg>
</div>
<div className="text-center">
<p className="font-bold text-base">Google Gemini</p>
<p className="text-xs text-muted-foreground mt-1">Gemini 2.0 Flash<br/>Gratuit (1500 req/j)</p>
</div>
{aiProvider === "gemini" && (
<Badge className="bg-green-500 text-white text-xs">Actif</Badge>
)}
</button>
<button
type="button"
onClick={() => setAiProvider("manus")}
@@ -938,6 +972,46 @@ export default function Settings() {
</div>
)}
{/* Gemini config */}
{aiProvider === "gemini" && (
<div className="space-y-4 p-4 bg-green-50 dark:bg-green-950/10 rounded-xl border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-green-700 dark:text-green-400 uppercase tracking-wide">Configuration Google Gemini</p>
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs font-normal">Gratuit — 1 500 req/jour</Badge>
</div>
<div className="space-y-2">
<Label htmlFor="geminiApiKey" className="font-medium">Clé API Google AI Studio</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? "text" : "password"}
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder="Votre clé API Google (ex: AIzaSy...)"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowGeminiKey(!showGeminiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showGeminiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Obtenez votre clé gratuite sur{" "}
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer" className="text-green-600 hover:underline">aistudio.google.com/apikey</a>.
Laissez vide pour utiliser la variable d'environnement GEMINI_API_KEY du serveur.
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-xs text-green-800 dark:text-green-300 font-medium">Modèle utilisé : <span className="font-bold">gemini-2.0-flash</span></p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">Analyse directe des PDF, extraction structurée JSON, compatible avec l'interface OpenAI.</p>
</div>
</div>
)}
{/* Manus config */}
{aiProvider === "manus" && (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-950/10 rounded-xl border border-blue-200 dark:border-blue-800">
@@ -993,14 +1067,14 @@ export default function Settings() {
<div>
<CardTitle className="text-xl">Configuration du modèle AI</CardTitle>
<CardDescription className="mt-1">
Paramètres du modèle d'extraction Mistral AI
Paramètres avancés du modèle d'extraction IA
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-3">
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle Mistral</Label>
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
<Input
id="llmModel"
value={llmModel}

View File

@@ -0,0 +1 @@
ALTER TABLE `importLogs` ADD `importSource` enum('file','folder','email') DEFAULT 'file' NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE `users` MODIFY COLUMN `azureAdId` varchar(128);

View File

@@ -0,0 +1,4 @@
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
ALTER TABLE `importSettings` ADD `geminiApiKey` text;--> statement-breakpoint
ALTER TABLE `userSettings` ADD `geminiApiKey` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,27 @@
"when": 1781021650065,
"tag": "0031_mean_squadron_supreme",
"breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1781690960721,
"tag": "0032_volatile_jocasta",
"breakpoints": true
},
{
"idx": 33,
"version": "5",
"when": 1783419609559,
"tag": "0033_strange_manta",
"breakpoints": true
},
{
"idx": 34,
"version": "5",
"when": 1785167359371,
"tag": "0034_black_shadowcat",
"breakpoints": true
}
]
}

View File

@@ -9,7 +9,7 @@ export const users = mysqlTable("users", {
/** Manus OAuth identifier (openId) - Optional for backward compatibility */
openId: varchar("openId", { length: 64 }).unique(),
/** Azure AD Object ID - Unique identifier from Azure AD */
azureAdId: varchar("azureAdId", { length: 64 }).unique(),
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
name: text("name"),
email: varchar("email", { length: 320 }).notNull().unique(),
/** Hashed password for local authentication (bcrypt) */
@@ -149,10 +149,11 @@ export const userSettings = mysqlTable("userSettings", {
// Seuil de confiance pour les apprentissages IA
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
// AI Engine configuration
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider: 'mistral' or 'manus'
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
@@ -196,10 +197,11 @@ export const importSettings = mysqlTable("importSettings", {
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
geminiApiKey: text("geminiApiKey"), // Google Gemini API key (overrides env GEMINI_API_KEY)
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
@@ -223,6 +225,8 @@ export const importLogs = mysqlTable("importLogs", {
duplicateDetails: text("duplicateDetails"), // JSON array of duplicate invoice info
errorDetails: text("errorDetails"), // JSON array of error messages
warningMessage: text("warningMessage"), // Warning message (e.g. quota exhausted)
/** Source du mode d'import : 'file' = upload manuel, 'folder' = dossier automatique, 'email' = import par email */
importSource: mysqlEnum("importSource", ["file", "folder", "email"]).default("file").notNull(),
importedAt: timestamp("importedAt").defaultNow().notNull(),
});

View File

@@ -8,4 +8,5 @@ export const ENV = {
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
};

View File

@@ -10,10 +10,11 @@ import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { getAllUsers } from "../db";
import { getAllUsers, getUserByAzureAdId, getUserByEmail, upsertUser } from "../db";
import { startEmailImportService } from "../emailImportService";
import { startFolderImportService } from "../folderImportService";
import { getImportSettingsByUser } from "../db";
import { handleAzureCallback, isAzureAdConfigured, generateToken } from "../auth";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
@@ -143,6 +144,86 @@ async function startServer() {
archive.finalize();
});
// ============= AZURE AD OAUTH2 CALLBACK =============
app.get("/api/auth/azure/callback", async (req, res) => {
const code = req.query.code as string | undefined;
const error = req.query.error as string | undefined;
if (error) {
console.error("[Azure AD] Erreur OAuth:", error, req.query.error_description);
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refusée")}`);
return;
}
if (!code) {
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
return;
}
if (!isAzureAdConfigured()) {
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configuré"));
return;
}
try {
const azureUser = await handleAzureCallback(code);
// Chercher l'utilisateur par azureAdId ou par email
let user = await getUserByAzureAdId(azureUser.azureAdId);
if (!user) {
user = await getUserByEmail(azureUser.email);
}
if (!user) {
// Créer l'utilisateur automatiquement
await upsertUser({
email: azureUser.email,
name: azureUser.name,
azureAdId: azureUser.azureAdId,
loginMethod: "azure-ad",
isActive: 1,
role: "user",
});
user = await getUserByEmail(azureUser.email);
} else {
// Mettre à jour l'azureAdId si manquant
if (!user.azureAdId) {
await upsertUser({
email: user.email,
azureAdId: azureUser.azureAdId,
loginMethod: user.loginMethod,
});
}
}
if (!user) {
res.redirect("/login?error=" + encodeURIComponent("Impossible de créer le compte"));
return;
}
if (user.isActive === 0) {
res.redirect("/login?error=" + encodeURIComponent("Compte inactif"));
return;
}
// Générer le token JWT et poser le cookie
const token = generateToken(user);
res.cookie("auth_token", token, {
httpOnly: true,
secure: false,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
console.log(`[Azure AD] Connexion réussie pour ${user.email}`);
res.redirect("/");
} catch (err: any) {
console.error("[Azure AD] Erreur callback:", err.message);
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
}
});
// tRPC API
app.use(
"/api/trpc",

View File

@@ -283,31 +283,45 @@ const normalizeResponseFormat = ({
/**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }): string {
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions";
}
if (provider === "gemini") {
// Google AI Studio — interface compatible OpenAI
return "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
}
// Manus provider
const forgeUrl = settings?.manusForgeApiUrl || ENV.forgeApiUrl || "https://forge.manus.ai";
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
}
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null }): string {
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || "";
}
if (provider === "gemini") {
return settings?.geminiApiKey || ENV.geminiApiKey || "";
}
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
}
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
}
/**
* Version de invokeLLM qui accepte les paramètres utilisateur depuis la DB
* pour choisir dynamiquement le moteur IA (Mistral ou Manus).
* pour choisir dynamiquement le moteur IA (Mistral, Manus ou Gemini).
*/
export async function invokeLLMWithUserSettings(
params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> {
const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) {
@@ -328,7 +342,7 @@ export async function invokeLLMWithUserSettings(
response_format,
} = params;
const model = isMistral ? "mistral-large-latest" : "gemini-2.5-flash";
const model = getModelForProvider(provider);
const payload: Record<string, unknown> = {
model,
@@ -342,7 +356,8 @@ export async function invokeLLMWithUserSettings(
payload.max_tokens = 32768;
if (!isMistral) {
// thinking uniquement pour Manus Forge (proxy Gemini)
if (provider === "manus") {
payload.thinking = { budget_tokens: 128 };
}

View File

@@ -268,11 +268,17 @@ export async function deleteInvoice(id: number) {
await db.delete(invoices).where(eq(invoices.id, id));
}
export async function searchInvoices(userId: number, query: string): Promise<Invoice[]> {
export async function searchInvoices(userId: number | null, query: string): Promise<Invoice[]> {
const db = await getDb();
if (!db) return [];
const searchPattern = `%${query}%`;
if (userId === null) {
// Admin : recherche globale sans filtre userId
return db.select().from(invoices)
.where(sql`(${invoices.supplierName} LIKE ${searchPattern} OR ${invoices.invoiceNumber} LIKE ${searchPattern})`)
.orderBy(desc(invoices.createdAt));
}
return db.select().from(invoices)
.where(
and(
@@ -485,11 +491,16 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
}
export async function deleteAllImportLogs(userId: number): Promise<void> {
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (userId === null) {
// Admin : supprime tous les logs
await db.delete(importLogs);
} else {
await db.delete(importLogs).where(eq(importLogs.userId, userId));
}
}
// ============= LLM LOG OPERATIONS =============
@@ -555,10 +566,10 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(userId: number): Promise<Department[]> {
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(departmentList).where(eq(departmentList.userId, userId)).orderBy(departmentList.name);
return await db.select().from(departmentList).orderBy(departmentList.name);
}
export async function createDepartment(data: InsertDepartment): Promise<Department> {
@@ -576,10 +587,10 @@ export async function deleteDepartment(id: number): Promise<void> {
// ============= ACCOUNTING ALLOCATION LIST OPERATIONS =============
export async function getAccountingAllocationsByUser(userId: number): Promise<AccountingAllocation[]> {
export async function getAccountingAllocationsByUser(_userId?: number): Promise<AccountingAllocation[]> {
const db = await getDb();
if (!db) return [];
return await db.select().from(accountingAllocationList).where(eq(accountingAllocationList.userId, userId)).orderBy(accountingAllocationList.name);
return await db.select().from(accountingAllocationList).orderBy(accountingAllocationList.name);
}
export async function createAccountingAllocation(data: InsertAccountingAllocation): Promise<AccountingAllocation> {
@@ -731,10 +742,10 @@ export async function initializeDefaultLlmFields(userId: number): Promise<void>
// ============= SIGNATURES HELPERS =============
export async function getSignaturesByUser(userId: number): Promise<Signature[]> {
export async function getSignaturesByUser(_userId?: number): Promise<Signature[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(signatures).where(eq(signatures.userId, userId));
return db.select().from(signatures);
}
export async function getSignatureById(id: number): Promise<Signature | undefined> {
@@ -762,10 +773,10 @@ export async function deleteSignature(id: number): Promise<void> {
// ============= SERVICE SIGNATURES HELPERS =============
export async function getServiceSignaturesByUser(userId: number): Promise<ServiceSignature[]> {
export async function getServiceSignaturesByUser(_userId?: number): Promise<ServiceSignature[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(serviceSignatures).where(eq(serviceSignatures.userId, userId));
return db.select().from(serviceSignatures);
}
export async function getServiceSignatureByService(userId: number, serviceName: string): Promise<ServiceSignature | undefined> {

View File

@@ -92,6 +92,7 @@ async function processEmailAttachment(
mistralApiKey: settings.mistralApiKey,
manusForgeApiKey: settings.manusForgeApiKey,
manusForgeApiUrl: settings.manusForgeApiUrl,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
// Extract invoices
@@ -232,6 +233,7 @@ async function processEmailAttachment(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage,
importSource: "email",
});
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);

View File

@@ -191,6 +191,7 @@ async function processFolderFile(
errors: errorsCount,
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
});
// Move file to processed folder

View File

@@ -218,6 +218,7 @@ export const appRouter = router({
mistralApiKey: (settings as any).mistralApiKey || null,
manusForgeApiKey: (settings as any).manusForgeApiKey || null,
manusForgeApiUrl: (settings as any).manusForgeApiUrl || null,
geminiApiKey: (settings as any).geminiApiKey || null,
} : undefined;
// Extract invoices
@@ -370,6 +371,7 @@ export const appRouter = router({
errors: errorsCount,
duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails),
importSource: "file",
});
} catch (error: any) {
@@ -398,7 +400,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return invoice;
@@ -424,7 +426,7 @@ export const appRouter = router({
}))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
@@ -440,7 +442,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
@@ -452,7 +454,7 @@ export const appRouter = router({
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Vérifier les critères de validation BAP
@@ -616,9 +618,11 @@ export const appRouter = router({
// ── Mise à jour de la facture ─────────────────────────────────────
const validatedAt = new Date();
const newExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(input.id, {
bapValidated: 1,
bapValidatedAt: validatedAt,
exportStatus: newExportStatus as any,
});
// ── Enregistrement dans l'historique BAP ─────────────────────────
@@ -663,7 +667,7 @@ export const appRouter = router({
let processed = 0;
for (const id of input.invoiceIds) {
const invoice = await getInvoiceById(id);
if (!invoice || invoice.userId !== ctx.user.id) continue;
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) continue;
await updateInvoice(id, {
bapValidated: 0,
bapValidatedAt: null,
@@ -677,7 +681,7 @@ export const appRouter = router({
// ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => {
const allInvoices = await getInvoicesByUser(ctx.user.id);
const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 &&
@@ -816,7 +820,8 @@ export const appRouter = router({
console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message);
}
// Mise à jour de la facture
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt });
const bulkExportStatus = (exportPath || pdfUrl) ? 'exported' : 'not_exported';
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt, exportStatus: bulkExportStatus as any });
// Historique
await createBapHistoryEntry({
userId: ctx.user.id,
@@ -858,7 +863,7 @@ export const appRouter = router({
.mutation(async ({ input, ctx }) => {
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
const { applyAutomationRules } = await import("./automationEngine");
const allInvoices = await getInvoicesByUser(ctx.user.id);
const allInvoices = ctx.user.role === 'admin' ? await getAllInvoices() : await getInvoicesByUser(ctx.user.id);
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
@@ -884,10 +889,121 @@ export const appRouter = router({
return { success: true, processed, errors, results };
}),
// ── Régénérer le PDF BAP pour une facture validée sans PDF ─────────────────
regenerateBapPdf: protectedProcedure
.input(z.object({ invoiceId: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.invoiceId);
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' });
}
if (!invoice.bapValidated) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' });
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// Récupération de la signature du service
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined;
let signatureName: string | null = null;
if (invoice.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (sigUrl) {
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (sigResp.ok) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// Placement du cartouche BAP
const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt,
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl
const allEntries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const latestEntry = allEntries
.filter(e => e.invoiceId === input.invoiceId)
.sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0];
if (latestEntry) {
await updateBapHistoryPdfUrl(latestEntry.id, url);
}
// Mettre à jour le statut export de la facture
await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any });
return { success: true, pdfUrl: url };
}),
// // ── Historique BAP ────────────────────
search: protectedProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => {
if (ctx.user.role === 'admin') {
return searchInvoices(null, input.query);
}
return searchInvoices(ctx.user.id, input.query);
}),
@@ -908,7 +1024,7 @@ export const appRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id);
const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteBapHistoryEntry(input.id);
@@ -919,12 +1035,12 @@ export const appRouter = router({
regenerate: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id);
const entries = ctx.user.role === 'admin' ? await getAllBapHistory() : await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
const invoice = await getInvoiceById(entry.invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture source introuvable' });
}
@@ -1056,10 +1172,11 @@ export const appRouter = router({
sftpAutoExport: z.number().optional(),
llmLogsRetentionMonths: z.number().optional(),
learningConfidenceThreshold: z.number().min(1).optional(),
aiProvider: z.enum(["mistral", "manus"]).optional(),
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
mistralApiKey: z.string().optional(),
manusForgeApiKey: z.string().optional(),
manusForgeApiUrl: z.string().optional(),
geminiApiKey: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
await upsertUserSettings({
@@ -1138,7 +1255,7 @@ export const appRouter = router({
);
const validInvoices = invoices.filter(
inv => inv && inv.userId === ctx.user.id
inv => inv && (ctx.user.role === 'admin' || inv.userId === ctx.user.id)
);
// Return invoice data for Excel generation on client side
@@ -1189,7 +1306,7 @@ export const appRouter = router({
);
const invalidInvoices = invoices.filter(
inv => !inv || inv.userId !== ctx.user.id || (inv.qualityScore || 0) < 100
inv => !inv || (ctx.user.role !== 'admin' && inv.userId !== ctx.user.id) || (inv.qualityScore || 0) < 100
);
if (invalidInvoices.length > 0) {
@@ -1448,7 +1565,7 @@ export const appRouter = router({
for (const invoiceId of input.invoiceIds) {
try {
const invoice = await getInvoiceById(invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) {
if (!invoice || (ctx.user.role !== 'admin' && invoice.userId !== ctx.user.id)) {
errorCount++;
continue;
}
@@ -1497,7 +1614,12 @@ export const appRouter = router({
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
// Les admins suppriment tous les logs, les utilisateurs standard suppriment les leurs
if (ctx.user.role === 'admin') {
await deleteAllImportLogs(null);
} else {
await deleteAllImportLogs(ctx.user.id);
}
return { success: true };
}),
}),

17
todo.md
View File

@@ -667,3 +667,20 @@
- [x] Job cron WebDev : vérification périodique selon fréquence configurée (setInterval en mémoire)
- [x] Frontend VentilationFreePro.tsx : wrapper Tabs (onglet 1 = import manuel, onglet 2 = paramétrage)
- [x] Onglet Paramétrage : formulaire credentials FreePro web, fréquence, date antériorité, bouton "Forcer récupération", statut dernière récupération
## Colonne Source import dans l'historique des imports
- [x] Ajouter colonne importSource (file/folder/email) dans la table importLogs (schéma + migration)
- [x] Enregistrer importSource="file" lors d'un import manuel (routers.ts)
- [x] Enregistrer importSource="email" lors d'un import par email (emailImportService.ts)
- [x] Enregistrer importSource="folder" lors d'un import par dossier (folderImportService.ts)
- [x] Afficher la colonne "Source" avec badge coloré dans le tableau History.tsx (Email=bleu, Dossier=violet, Fichier=gris)
- [x] Afficher la source dans le dialog de détails de l'import
## Listes globales partagées entre tous les utilisateurs
- [ ] Modifier getAccountingAllocationsByUser → retourner toutes les ventilations (sans filtre userId)
- [ ] Modifier getAutomationRulesByUser → retourner tous les automatismes (sans filtre userId)
- [ ] Modifier getDepartmentsByUser → retourner tous les services (sans filtre userId)
- [ ] Modifier getSignaturesByUser → retourner toutes les signatures (sans filtre userId)
- [ ] Modifier getServiceSignaturesByUser → retourner toutes les signatures de service (sans filtre userId)
- [ ] Migrer les données en production : dédoublonner les listes fusionnées
- [ ] Déployer en production